Open telemetry integration (#3972)

This PR adds opentelemetry and chi wrapper to have basic instrumentation

<!--start release-notes-assistant-->

## Draft release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/3972): <!--number 3972 --><!--line 0 --><!--description YWRkIHN1cHBvcnQgZm9yIGJhc2ljIHJlcXVlc3QgdHJhY2luZyB3aXRoIG9wZW50ZWxlbWV0cnk=-->add support for basic request tracing with opentelemetry<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/3972
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-committed-by: TheFox0x7 <thefox0x7@gmail.com>
This commit is contained in:
TheFox0x7 2024-08-05 06:04:39 +00:00 committed by Earl Warren
parent 7c74def6ff
commit c738542201
19 changed files with 1281 additions and 10 deletions

View file

@ -0,0 +1,199 @@
// Copyright 2024 TheFox0x7. All rights reserved.
// SPDX-License-Identifier: EUPL-1.2
package setting
import (
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/log"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
const (
opentelemetrySectionName string = "opentelemetry"
exporter string = ".exporter"
otlp string = ".otlp"
alwaysOn string = "always_on"
alwaysOff string = "always_off"
traceIDRatio string = "traceidratio"
parentBasedAlwaysOn string = "parentbased_always_on"
parentBasedAlwaysOff string = "parentbased_always_off"
parentBasedTraceIDRatio string = "parentbased_traceidratio"
)
var OpenTelemetry = struct {
// Inverse of OTEL_SDK_DISABLE, skips telemetry setup
Enabled bool
ServiceName string
ResourceAttributes string
ResourceDetectors string
Sampler sdktrace.Sampler
Traces string
OtelTraces *OtelExporter
}{
ServiceName: "forgejo",
Traces: "otel",
}
type OtelExporter struct {
Endpoint *url.URL `ini:"ENDPOINT"`
Headers map[string]string `ini:"-"`
Compression string `ini:"COMPRESSION"`
Certificate string `ini:"CERTIFICATE"`
ClientKey string `ini:"CLIENT_KEY"`
ClientCertificate string `ini:"CLIENT_CERTIFICATE"`
Timeout time.Duration `ini:"TIMEOUT"`
Protocol string `ini:"-"`
}
func createOtlpExporterConfig(rootCfg ConfigProvider, section string) *OtelExporter {
protocols := []string{"http/protobuf", "grpc"}
endpoint, _ := url.Parse("http://localhost:4318/")
exp := &OtelExporter{
Endpoint: endpoint,
Timeout: 10 * time.Second,
Headers: map[string]string{},
Protocol: "http/protobuf",
}
loadSection := func(name string) {
otlp := rootCfg.Section(name)
if otlp.HasKey("ENDPOINT") {
endpoint, err := url.Parse(otlp.Key("ENDPOINT").String())
if err != nil {
log.Warn("Endpoint parsing failed, section: %s, err %v", name, err)
} else {
exp.Endpoint = endpoint
}
}
if err := otlp.MapTo(exp); err != nil {
log.Warn("Mapping otlp settings failed, section: %s, err: %v", name, err)
}
exp.Protocol = otlp.Key("PROTOCOL").In(exp.Protocol, protocols)
headers := otlp.Key("HEADERS").String()
if headers != "" {
for k, v := range _stringToHeader(headers) {
exp.Headers[k] = v
}
}
}
loadSection("opentelemetry.exporter.otlp")
loadSection("opentelemetry.exporter.otlp" + section)
if len(exp.Certificate) > 0 && !filepath.IsAbs(exp.Certificate) {
exp.Certificate = filepath.Join(CustomPath, exp.Certificate)
}
if len(exp.ClientCertificate) > 0 && !filepath.IsAbs(exp.ClientCertificate) {
exp.ClientCertificate = filepath.Join(CustomPath, exp.ClientCertificate)
}
if len(exp.ClientKey) > 0 && !filepath.IsAbs(exp.ClientKey) {
exp.ClientKey = filepath.Join(CustomPath, exp.ClientKey)
}
return exp
}
func loadOpenTelemetryFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section(opentelemetrySectionName)
OpenTelemetry.Enabled = sec.Key("ENABLED").MustBool(false)
if !OpenTelemetry.Enabled {
return
}
// Load resource related settings
OpenTelemetry.ServiceName = sec.Key("SERVICE_NAME").MustString("forgejo")
OpenTelemetry.ResourceAttributes = sec.Key("RESOURCE_ATTRIBUTES").String()
OpenTelemetry.ResourceDetectors = strings.ToLower(sec.Key("RESOURCE_DETECTORS").String())
// Load tracing related settings
samplers := make([]string, 0, len(sampler))
for k := range sampler {
samplers = append(samplers, k)
}
samplerName := sec.Key("TRACES_SAMPLER").In(parentBasedAlwaysOn, samplers)
samplerArg := sec.Key("TRACES_SAMPLER_ARG").MustString("")
OpenTelemetry.Sampler = sampler[samplerName](samplerArg)
switch sec.Key("TRACES_EXPORTER").MustString("otlp") {
case "none":
OpenTelemetry.Traces = "none"
default:
OpenTelemetry.Traces = "otlp"
OpenTelemetry.OtelTraces = createOtlpExporterConfig(rootCfg, ".traces")
}
}
var sampler = map[string]func(arg string) sdktrace.Sampler{
alwaysOff: func(_ string) sdktrace.Sampler {
return sdktrace.NeverSample()
},
alwaysOn: func(_ string) sdktrace.Sampler {
return sdktrace.AlwaysSample()
},
traceIDRatio: func(arg string) sdktrace.Sampler {
ratio, err := strconv.ParseFloat(arg, 64)
if err != nil {
ratio = 1
}
return sdktrace.TraceIDRatioBased(ratio)
},
parentBasedAlwaysOff: func(_ string) sdktrace.Sampler {
return sdktrace.ParentBased(sdktrace.NeverSample())
},
parentBasedAlwaysOn: func(_ string) sdktrace.Sampler {
return sdktrace.ParentBased(sdktrace.AlwaysSample())
},
parentBasedTraceIDRatio: func(arg string) sdktrace.Sampler {
ratio, err := strconv.ParseFloat(arg, 64)
if err != nil {
ratio = 1
}
return sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))
},
}
// Opentelemetry SDK function port
func _stringToHeader(value string) map[string]string {
headersPairs := strings.Split(value, ",")
headers := make(map[string]string)
for _, header := range headersPairs {
n, v, found := strings.Cut(header, "=")
if !found {
log.Warn("Otel header ignored on %q: missing '='", header)
continue
}
name, err := url.PathUnescape(n)
if err != nil {
log.Warn("Otel header ignored on %q, invalid header key: %s", header, n)
continue
}
trimmedName := strings.TrimSpace(name)
value, err := url.PathUnescape(v)
if err != nil {
log.Warn("Otel header ignored on %q, invalid header value: %s", header, v)
continue
}
trimmedValue := strings.TrimSpace(value)
headers[trimmedName] = trimmedValue
}
return headers
}
func IsOpenTelemetryEnabled() bool {
return OpenTelemetry.Enabled
}

View file

@ -0,0 +1,239 @@
// Copyright 2024 TheFox0x7. All rights reserved.
// SPDX-License-Identifier: EUPL-1.2
package setting
import (
"net/url"
"testing"
"time"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func TestExporterLoad(t *testing.T) {
globalSetting := `
[opentelemetry.exporter.otlp]
ENDPOINT=http://example.org:4318/
CERTIFICATE=/boo/bar
CLIENT_CERTIFICATE=/foo/bar
CLIENT_KEY=/bar/bar
COMPRESSION=
HEADERS=key=val,val=key
PROTOCOL=http/protobuf
TIMEOUT=20s
`
endpoint, err := url.Parse("http://example.org:4318/")
require.NoError(t, err)
expected := &OtelExporter{
Endpoint: endpoint,
Certificate: "/boo/bar",
ClientCertificate: "/foo/bar",
ClientKey: "/bar/bar",
Headers: map[string]string{
"key": "val", "val": "key",
},
Timeout: 20 * time.Second,
Protocol: "http/protobuf",
}
cfg, err := NewConfigProviderFromData(globalSetting)
require.NoError(t, err)
exp := createOtlpExporterConfig(cfg, ".traces")
assert.Equal(t, expected, exp)
localSetting := `
[opentelemetry.exporter.otlp.traces]
ENDPOINT=http://example.com:4318/
CERTIFICATE=/boo
CLIENT_CERTIFICATE=/foo
CLIENT_KEY=/bar
COMPRESSION=gzip
HEADERS=key=val2,val1=key
PROTOCOL=grpc
TIMEOUT=5s
`
endpoint, err = url.Parse("http://example.com:4318/")
require.NoError(t, err)
expected = &OtelExporter{
Endpoint: endpoint,
Certificate: "/boo",
ClientCertificate: "/foo",
ClientKey: "/bar",
Compression: "gzip",
Headers: map[string]string{
"key": "val2", "val1": "key", "val": "key",
},
Timeout: 5 * time.Second,
Protocol: "grpc",
}
cfg, err = NewConfigProviderFromData(globalSetting + localSetting)
require.NoError(t, err)
exp = createOtlpExporterConfig(cfg, ".traces")
require.NoError(t, err)
assert.Equal(t, expected, exp)
}
func TestOpenTelemetryConfiguration(t *testing.T) {
defer test.MockProtect(&OpenTelemetry)()
iniStr := ``
cfg, err := NewConfigProviderFromData(iniStr)
require.NoError(t, err)
loadOpenTelemetryFrom(cfg)
assert.Nil(t, OpenTelemetry.OtelTraces)
assert.False(t, IsOpenTelemetryEnabled())
iniStr = `
[opentelemetry]
ENABLED=true
SERVICE_NAME = test service
RESOURCE_ATTRIBUTES = foo=bar
TRACES_SAMPLER = always_on
[opentelemetry.exporter.otlp]
ENDPOINT = http://jaeger:4317/
TIMEOUT = 30s
COMPRESSION = gzip
INSECURE = TRUE
HEADERS=foo=bar,overwrite=false
`
cfg, err = NewConfigProviderFromData(iniStr)
require.NoError(t, err)
loadOpenTelemetryFrom(cfg)
assert.True(t, IsOpenTelemetryEnabled())
assert.Equal(t, "test service", OpenTelemetry.ServiceName)
assert.Equal(t, "foo=bar", OpenTelemetry.ResourceAttributes)
assert.Equal(t, 30*time.Second, OpenTelemetry.OtelTraces.Timeout)
assert.Equal(t, "gzip", OpenTelemetry.OtelTraces.Compression)
assert.Equal(t, sdktrace.AlwaysSample(), OpenTelemetry.Sampler)
assert.Equal(t, "http://jaeger:4317/", OpenTelemetry.OtelTraces.Endpoint.String())
assert.Contains(t, OpenTelemetry.OtelTraces.Headers, "foo")
assert.Equal(t, "bar", OpenTelemetry.OtelTraces.Headers["foo"])
assert.Contains(t, OpenTelemetry.OtelTraces.Headers, "overwrite")
assert.Equal(t, "false", OpenTelemetry.OtelTraces.Headers["overwrite"])
}
func TestOpenTelemetryTraceDisable(t *testing.T) {
defer test.MockProtect(&OpenTelemetry)()
iniStr := ``
cfg, err := NewConfigProviderFromData(iniStr)
require.NoError(t, err)
loadOpenTelemetryFrom(cfg)
assert.False(t, OpenTelemetry.Enabled)
assert.False(t, IsOpenTelemetryEnabled())
iniStr = `
[opentelemetry]
ENABLED=true
EXPORTER_OTLP_ENDPOINT =
`
cfg, err = NewConfigProviderFromData(iniStr)
require.NoError(t, err)
loadOpenTelemetryFrom(cfg)
assert.True(t, IsOpenTelemetryEnabled())
endpoint, _ := url.Parse("http://localhost:4318/")
assert.Equal(t, endpoint, OpenTelemetry.OtelTraces.Endpoint)
}
func TestSamplerCombinations(t *testing.T) {
defer test.MockProtect(&OpenTelemetry)()
type config struct {
IniCfg string
Expected sdktrace.Sampler
}
testSamplers := []config{
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = always_on
TRACES_SAMPLER_ARG = nothing`, sdktrace.AlwaysSample()},
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = always_off`, sdktrace.NeverSample()},
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = traceidratio
TRACES_SAMPLER_ARG = 0.7`, sdktrace.TraceIDRatioBased(0.7)},
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = traceidratio
TRACES_SAMPLER_ARG = badarg`, sdktrace.TraceIDRatioBased(1)},
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = parentbased_always_off`, sdktrace.ParentBased(sdktrace.NeverSample())},
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = parentbased_always_of`, sdktrace.ParentBased(sdktrace.AlwaysSample())},
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = parentbased_traceidratio
TRACES_SAMPLER_ARG = 0.3`, sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.3))},
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = parentbased_traceidratio
TRACES_SAMPLER_ARG = badarg`, sdktrace.ParentBased(sdktrace.TraceIDRatioBased(1))},
{`[opentelemetry]
ENABLED=true
TRACES_SAMPLER = not existing
TRACES_SAMPLER_ARG = badarg`, sdktrace.ParentBased(sdktrace.AlwaysSample())},
}
for _, sampler := range testSamplers {
cfg, err := NewConfigProviderFromData(sampler.IniCfg)
require.NoError(t, err)
loadOpenTelemetryFrom(cfg)
assert.Equal(t, sampler.Expected, OpenTelemetry.Sampler)
}
}
func TestOpentelemetryBadConfigs(t *testing.T) {
defer test.MockProtect(&OpenTelemetry)()
iniStr := `
[opentelemetry]
ENABLED=true
[opentelemetry.exporter.otlp]
ENDPOINT = jaeger:4317/
`
cfg, err := NewConfigProviderFromData(iniStr)
require.NoError(t, err)
loadOpenTelemetryFrom(cfg)
assert.True(t, IsOpenTelemetryEnabled())
assert.Equal(t, "jaeger:4317/", OpenTelemetry.OtelTraces.Endpoint.String())
iniStr = ``
cfg, err = NewConfigProviderFromData(iniStr)
require.NoError(t, err)
loadOpenTelemetryFrom(cfg)
assert.False(t, IsOpenTelemetryEnabled())
iniStr = `
[opentelemetry]
ENABLED=true
SERVICE_NAME =
TRACES_SAMPLER = not existing one
[opentelemetry.exporter.otlp]
ENDPOINT = http://jaeger:4317/
TIMEOUT = abc
COMPRESSION = foo
HEADERS=%s=bar,foo=%h,foo
`
cfg, err = NewConfigProviderFromData(iniStr)
require.NoError(t, err)
loadOpenTelemetryFrom(cfg)
assert.True(t, IsOpenTelemetryEnabled())
assert.Equal(t, "forgejo", OpenTelemetry.ServiceName)
assert.Equal(t, 10*time.Second, OpenTelemetry.OtelTraces.Timeout)
assert.Equal(t, sdktrace.ParentBased(sdktrace.AlwaysSample()), OpenTelemetry.Sampler)
assert.Equal(t, "http://jaeger:4317/", OpenTelemetry.OtelTraces.Endpoint.String())
assert.Empty(t, OpenTelemetry.OtelTraces.Headers)
}

View file

@ -150,6 +150,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadAPIFrom(cfg)
loadBadgesFrom(cfg)
loadMetricsFrom(cfg)
loadOpenTelemetryFrom(cfg)
loadCamoFrom(cfg)
loadI18nFrom(cfg)
loadGitFrom(cfg)