Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Telemetry attributes: follow rules in https://github.com/getlantern/semconv/blob/main/AGENTS.md
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ require (
github.com/getlantern/geo v0.0.0-20241129152027-2fc88c10f91e
github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90
github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974
github.com/getlantern/semconv v0.0.0-20260311010754-121098b82c93
github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb
github.com/gobwas/ws v1.4.0
github.com/refraction-networking/water v0.7.1-alpha
github.com/sagernet/sing v0.7.18
Expand All @@ -28,7 +28,7 @@ require (
github.com/tetratelabs/wazero v1.11.0
go.opentelemetry.io/otel v1.41.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0
go.opentelemetry.io/otel/log v0.16.0
go.opentelemetry.io/otel/metric v1.41.0
Expand Down
10 changes: 4 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,10 @@ github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA
github.com/getlantern/ops v0.0.0-20220713155959-1315d978fff7/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag=
github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534/go.mod h1:ZsLfOY6gKQOTyEcPYNA9ws5/XHZQFroxqCOhHjGcs9Y=
github.com/getlantern/samizdat v0.0.3-0.20260310125445-325cf1bd1b60 h1:m9eXjDK9vllbVH467+QXbrxUFFM9Yp7YJ90wZLw4dwU=
github.com/getlantern/samizdat v0.0.3-0.20260310125445-325cf1bd1b60/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0=
github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974 h1:k+/qNo5YNO+8M8LVUp6G5Evm1OQdEs3Z4ye8top4AhI=
github.com/getlantern/samizdat v0.0.3-0.20260327203406-ef7323341974/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0=
github.com/getlantern/semconv v0.0.0-20260311010754-121098b82c93 h1:QtjFuMY8+r9KF5F5wh/feQTWI1V5SNfPEfkg9XW7IlQ=
github.com/getlantern/semconv v0.0.0-20260311010754-121098b82c93/go.mod h1:VxL0T8TnlxSCCvVPZ/hrIDczTnrhcB3tHgmtrCjZrts=
github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb h1:c5YM7b3a4r2J8Eh89KkI6M/iTFe6Bi+b8AJlfkKdFq4=
github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb/go.mod h1:GkPT5P9JoOTIRXRmFWxYgu1hhXgTFFTNc2hoG7WQc3g=
github.com/getlantern/sing v0.7.18-lantern h1:QKGgIUA3LwmKYP/7JlQTRkxj9jnP4cX2Q/B+nd8XEjo=
github.com/getlantern/sing v0.7.18-lantern/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/getlantern/sing-box-minimal v1.12.20-lantern h1:dWdR/z0A1Ukn+kcOIgH7+aOc9+yh8kvQ3Odw53+uocQ=
Expand Down Expand Up @@ -780,8 +778,8 @@ go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
Expand Down
17 changes: 16 additions & 1 deletion otel/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"runtime/debug"
"time"

"github.com/sagernet/sing-box/log"
Expand All @@ -16,7 +17,7 @@ import (
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
semconv "github.com/getlantern/semconv"
)

// Enabled checks if an OTLP endpoint is configured via standard OTEL_EXPORTER_OTLP_* env vars.
Expand Down Expand Up @@ -95,10 +96,24 @@ func deltaForCounters(kind sdkmetric.InstrumentKind) metricdata.Temporality {
func buildResource(extras ...attribute.KeyValue) *resource.Resource {
attrs := append([]attribute.KeyValue{
semconv.ServiceNameKey.String("lantern-box"),
semconv.ServiceVersionKey.String(vcsRevision()),
}, extras...)
Comment thread
jay-418 marked this conversation as resolved.
r, _ := resource.New(context.Background(),
resource.WithAttributes(attrs...),
resource.WithFromEnv(),
)
return r
}

func vcsRevision() string {
bi, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
for _, s := range bi.Settings {
if s.Key == "vcs.revision" {
return s.Value
}
}
return "unknown"
}
15 changes: 8 additions & 7 deletions otel/otel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"github.com/testcontainers/testcontainers-go/wait"
sdkotel "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
semconv "github.com/getlantern/semconv"
)

func TestEnabled(t *testing.T) {
Expand Down Expand Up @@ -56,14 +56,15 @@ func TestEnabled(t *testing.T) {
func TestBuildResource(t *testing.T) {
t.Run("default service name", func(t *testing.T) {
r := buildResource()
var found bool
m := make(map[attribute.Key]attribute.Value)
for _, attr := range r.Attributes() {
if attr.Key == semconv.ServiceNameKey {
assert.Equal(t, "lantern-box", attr.Value.AsString())
found = true
}
m[attr.Key] = attr.Value
}
assert.True(t, found, "service.name attribute not found")
assert.Equal(t, "lantern-box",
m[semconv.ServiceNameKey].AsString())
assert.NotEmpty(t,
m[semconv.ServiceVersionKey].AsString(),
"service.version should be set")
})

t.Run("OTEL_SERVICE_NAME overrides default", func(t *testing.T) {
Expand Down
35 changes: 26 additions & 9 deletions tracker/metrics/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
otelsc "go.opentelemetry.io/otel/semconv/v1.37.0"

"github.com/getlantern/lantern-box/tracker/clientcontext"
)
Expand Down Expand Up @@ -69,8 +68,13 @@ func trackIOLoop(ctx context.Context, reportC <-chan report) {
return
case r := <-reportC:
attrs := append(r.attrs.AsSlice(),
otelsc.NetworkIODirectionKey.String(string(r.direction)),
semconv.NetworkIODirectionKey.String(string(r.direction)),
)
if r.attrs.client != nil {
attrs = append(attrs,
semconv.ClientDeviceIDKey.String(r.attrs.client.DeviceID),
)
}
metrics.ProxyIO.Add(context.Background(), int64(r.n), metric.WithAttributes(attrs...))
}
}
Expand All @@ -91,7 +95,7 @@ func emitDeviceConnectedSpan(ctx context.Context) {
semconv.ClientDeviceIDKey.String(info.DeviceID),
semconv.ClientPlatformKey.String(info.Platform),
semconv.ClientIsProKey.Bool(info.IsPro),
otelsc.GeoCountryISOCodeKey.String(info.CountryCode),
semconv.GeoCountryISOCodeKey.String(info.CountryCode),
semconv.ClientVersionKey.String(info.Version),
)
span.End()
Expand All @@ -100,40 +104,53 @@ func emitDeviceConnectedSpan(ctx context.Context) {
func (t *MetricsTracker) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn {
emitDeviceConnectedSpan(ctx)
attrs := metadataToAttributes(metadata)
if info, ok := clientcontext.ClientInfoFromContext(ctx); ok {
attrs.client = &info
}
Comment thread
jay-418 marked this conversation as resolved.
metrics.conns.Add(context.Background(), 1, metric.WithAttributes(attrs.AsSlice()...))
return NewConn(conn, attrs, t)
}

func (t *MetricsTracker) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn {
emitDeviceConnectedSpan(ctx)
attrs := metadataToAttributes(metadata)
if info, ok := clientcontext.ClientInfoFromContext(ctx); ok {
attrs.client = &info
}
metrics.conns.Add(context.Background(), 1, metric.WithAttributes(attrs.AsSlice()...))
return NewPacketConn(conn, attrs, t)
}

func (t *MetricsTracker) Leave(duration int64, attrs *attributes) {
a := append(attrs.attrs,
otelsc.GeoCountryISOCodeKey.String(attrs.country.Load().(string)),
)
a := attrs.AsSlice()
metrics.duration.Record(context.Background(), duration, metric.WithAttributes(a...))
metrics.conns.Add(context.Background(), -1, metric.WithAttributes(a...))
}

type attributes struct {
attrs []attribute.KeyValue
country atomic.Value // string
client *clientcontext.ClientInfo
}

func (a *attributes) AsSlice() []attribute.KeyValue {
return append(a.attrs,
otelsc.GeoCountryISOCodeKey.String(a.country.Load().(string)),
s := append(a.attrs,
semconv.GeoCountryISOCodeKey.String(a.country.Load().(string)),
)
if a.client != nil {
s = append(s,
semconv.ClientPlatformKey.String(a.client.Platform),
semconv.ClientIsProKey.Bool(a.client.IsPro),
semconv.ClientVersionKey.String(a.client.Version),
)
Comment thread
jay-418 marked this conversation as resolved.
}
return s
}

func metadataToAttributes(metadata adapter.InboundContext) *attributes {
attrs := &attributes{
attrs: []attribute.KeyValue{
otelsc.NetworkProtocolNameKey.String(metadata.Protocol),
semconv.NetworkProtocolNameKey.String(metadata.Protocol),
semconv.ProxyInboundKey.String(metadata.Inbound),
semconv.ProxyInboundTypeKey.String(metadata.InboundType),
semconv.ProxyOutboundKey.String(metadata.Outbound),
Expand Down
112 changes: 112 additions & 0 deletions tracker/metrics/tracker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,81 @@ func TestTracker(t *testing.T) {
})
}

func TestTrackerWithClientInfo(t *testing.T) {
synctest.Run(func() {
reader := metric.NewManualReader()
provider := metric.NewMeterProvider(metric.WithReader(reader))
sdkotel.SetMeterProvider(provider)

SetupMetricsManager(geo.NoLookup{})

info := clientcontext.ClientInfo{
DeviceID: "dev-42",
Platform: "android",
IsPro: true,
Version: "7.0",
}
ctx := clientcontext.ContextWithClientInfo(
context.Background(), info,
)
tracker := NewTracker(ctx)
defer tracker.Close()

client, server := net.Pipe()
defer client.Close()
defer server.Close()

tracked := tracker.RoutedConnection(
ctx, server, adapter.InboundContext{}, nil, nil,
)

// Exchange some bytes so proxy.io fires.
go func() {
buf := make([]byte, 16)
_, _ = tracked.Read(buf)
}()
_, _ = client.Write([]byte("hello"))
synctest.Wait()

// Close triggers Leave → duration + conns-1.
tracked.Close()
synctest.Wait()

var rm metricdata.ResourceMetrics
reader.Collect(ctx, &rm)

// All metrics carry low-cardinality client attrs.
for _, name := range []string{
"proxy.io",
"sing.connections",
"sing.connection_duration",
} {
attrs := extractAttrs(rm, name)
assert.Equal(t, "android",
attrs["client.platform"],
"%s: platform", name)
assert.Equal(t, true,
attrs["client.is_pro"],
"%s: is_pro", name)
assert.Equal(t, "7.0",
attrs["client.version"],
"%s: version", name)
}

// device_id only on proxy.io (high-cardinality).
ioAttrs := extractAttrs(rm, "proxy.io")
assert.Equal(t, "dev-42", ioAttrs["client.device_id"])

connAttrs := extractAttrs(rm, "sing.connections")
assert.Nil(t, connAttrs["client.device_id"],
"sing.connections should not have device_id")

durAttrs := extractAttrs(rm, "sing.connection_duration")
assert.Nil(t, durAttrs["client.device_id"],
"sing.connection_duration should not have device_id")
})
}

func TestDeviceConnectedSpan(t *testing.T) {
exporter := tracetest.NewInMemoryExporter()
tp := sdktrace.NewTracerProvider(
Expand Down Expand Up @@ -160,3 +235,40 @@ func extractCountersByAttribute(rm metricdata.ResourceMetrics, name string) map[
}
return result
}

// extractAttrs collects the attribute key→value pairs from the
// first data point of the named metric, across all aggregation types.
func extractAttrs(rm metricdata.ResourceMetrics, name string) map[string]any {
for _, sm := range rm.ScopeMetrics {
for _, m := range sm.Metrics {
if m.Name != name {
continue
}
var set attribute.Set
switch d := m.Data.(type) {
case metricdata.Sum[int64]:
if len(d.DataPoints) > 0 {
set = d.DataPoints[0].Attributes
}
case metricdata.Histogram[int64]:
if len(d.DataPoints) > 0 {
set = d.DataPoints[0].Attributes
}
case metricdata.Histogram[float64]:
if len(d.DataPoints) > 0 {
set = d.DataPoints[0].Attributes
}
default:
continue
}
out := make(map[string]any)
iter := set.Iter()
for iter.Next() {
kv := iter.Attribute()
out[string(kv.Key)] = kv.Value.AsInterface()
}
return out
}
}
return nil
}
Loading