mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-08-04 02:12:30 +02:00
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:
parent
7c74def6ff
commit
c738542201
19 changed files with 1281 additions and 10 deletions
199
modules/setting/opentelemetry.go
Normal file
199
modules/setting/opentelemetry.go
Normal 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
|
||||
}
|
239
modules/setting/opentelemetry_test.go
Normal file
239
modules/setting/opentelemetry_test.go
Normal 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)
|
||||
}
|
|
@ -150,6 +150,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
|||
loadAPIFrom(cfg)
|
||||
loadBadgesFrom(cfg)
|
||||
loadMetricsFrom(cfg)
|
||||
loadOpenTelemetryFrom(cfg)
|
||||
loadCamoFrom(cfg)
|
||||
loadI18nFrom(cfg)
|
||||
loadGitFrom(cfg)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue