From e3e5e0c96ab9cd36b59fd0f3180baa833cd3cbe8 Mon Sep 17 00:00:00 2001 From: Rahul Srivastava Date: Fri, 26 Jun 2026 19:33:56 +0530 Subject: [PATCH] feat: opt-in OpenTelemetry tracing Off by default. Opt in via CRDB_MCP_OTEL_FILE (JSON spans to a file) or OTEL_EXPORTER_OTLP_ENDPOINT (OTLP gRPC to any collector, honoring standard OTEL_* env vars). Neither set: providers stay no-op. A receiving middleware wraps each MCP tools/call in a span named after the tool. db/adapter hand-instruments Query and Exec with SQL child spans; db.query.text is redacted via cockroachdb-parser (FmtAnonymize + FmtHideConstants), matching managed-service, so no user data lands in span attributes. Zap records also mirror to the OTel log pipeline via the otelzap bridge; trace-context propagation onto log records is a follow-up. --- README.md | 27 +++++++- config/config.go | 11 +++ config/config_test.go | 23 ++++++- db/adapter.go | 92 ++++++++++++++++++++++++- db/adapter_test.go | 57 ++++++++++++++++ go.mod | 54 ++++++++------- go.sum | 112 +++++++++++++++++-------------- main.go | 18 ++++- middleware/otel.go | 41 ++++++++++++ middleware/otel_test.go | 129 +++++++++++++++++++++++++++++++++++ otel/otel.go | 144 ++++++++++++++++++++++++++++++++++++++++ otel/otel_test.go | 111 +++++++++++++++++++++++++++++++ 12 files changed, 740 insertions(+), 79 deletions(-) create mode 100644 middleware/otel.go create mode 100644 middleware/otel_test.go create mode 100644 otel/otel.go create mode 100644 otel/otel_test.go diff --git a/README.md b/README.md index 576137b..9e5e51d 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,6 @@ tools by default; write and DDL tools opt in via env var. Requires Go 1.25+ and a reachable CockroachDB cluster. -### `go install` - ```bash go install github.com/cockroachdb/cockroachdb-mcp-server@latest ``` @@ -197,6 +195,31 @@ Precedence for picking the value: | `CRDB_MCP_LOG_LEVEL` | `debug`, `info`, `warn`, `error` | `info` | | `CRDB_MCP_LOG_PATH` | Log file path, or `-` for stderr. No rotation; use logrotate or your orchestrator | - | +### Tracing (OpenTelemetry) + +Tracing is opt-in: with neither variable below set, no exporter is installed. +When enabled, tool calls and their SQL statements are exported as spans, and +server logs as OTel log records. Query text and errors are redacted before +export so literals and user data never leave the server. + +| Variable | Purpose | Default | +| --- | --- | --- | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP gRPC endpoint; the standard `OTEL_*` env vars are honored | - | +| `CRDB_MCP_OTEL_FILE` | Write traces and logs as JSON lines to this file instead (takes precedence) | - | + +In stdio mode, add the variable to the `env` block of your +[MCP client config](#setup---mcp-client-config); in HTTP mode, export it in +the server's environment: + +```json +{ + "env": { + "CRDB_DATABASE_URL": "postgresql://...", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317" + } +} +``` + ## Tools Grant the connecting SQL role only the privileges the registered tools need. diff --git a/config/config.go b/config/config.go index a5d3af4..c5ed0e4 100644 --- a/config/config.go +++ b/config/config.go @@ -38,6 +38,8 @@ const ( envTxnQoS = "CRDB_MCP_TXN_QOS" envLogLevel = "CRDB_MCP_LOG_LEVEL" envLogPath = "CRDB_MCP_LOG_PATH" + envOTelFile = "CRDB_MCP_OTEL_FILE" + envOTLPEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" defaultPort = 26257 defaultSSLMode = "verify-full" @@ -91,6 +93,8 @@ type Config struct { // AllowNoBearer lets HTTP mode start without a bearer token. Auth must // then be provided upstream (reverse proxy, gateway, mTLS). AllowNoBearer bool + OTelFile string + OTLPEndpoint string } // Load reads configuration from environment variables. @@ -112,6 +116,8 @@ func Load() (*Config, error) { TLSKey: os.Getenv(envTLSKey), LogLevel: defaultLogLevel, LogPath: os.Getenv(envLogPath), + OTelFile: os.Getenv(envOTelFile), + OTLPEndpoint: os.Getenv(envOTLPEndpoint), } if raw := os.Getenv(envTransport); raw != "" { cfg.Transport = raw @@ -354,6 +360,11 @@ func (c *Config) TLSEnabled() bool { return c.TLSCert != "" && c.TLSKey != "" } +// OTelEnabled reports whether an OpenTelemetry exporter is configured. +func (c *Config) OTelEnabled() bool { + return c.OTelFile != "" || c.OTLPEndpoint != "" +} + // validateHTTPTLS enforces the SECSERV-422 default-secure policy: HTTP mode // must serve TLS unless the operator explicitly opts into cleartext via // CRDB_MCP_ALLOW_INSECURE_HTTP=true. Cert and key are required together, and diff --git a/config/config_test.go b/config/config_test.go index 6c2cada..207868a 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -37,6 +37,27 @@ func TestLoad(t *testing.T) { require.Empty(t, cfg.TxnQoS, "txn qos is empty unless env var is explicit; adapter applies the fallback") require.Equal(t, defaultLogLevel, cfg.LogLevel) require.Empty(t, cfg.LogPath, "log path defaults to empty (stderr)") + require.Empty(t, cfg.OTelFile, "otel file is opt-in") + require.Empty(t, cfg.OTLPEndpoint, "otlp endpoint is opt-in") + require.False(t, cfg.OTelEnabled(), "OTel is off when neither env var is set") + }) + + t.Run("otel file path is captured", func(t *testing.T) { + env := mergeEnv(baseEnv, map[string]string{envOTelFile: "/var/log/otel.jsonl"}) + setEnv(t, env) + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "/var/log/otel.jsonl", cfg.OTelFile) + require.True(t, cfg.OTelEnabled()) + }) + + t.Run("otlp endpoint is captured", func(t *testing.T) { + env := mergeEnv(baseEnv, map[string]string{envOTLPEndpoint: "otel-collector:4317"}) + setEnv(t, env) + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "otel-collector:4317", cfg.OTLPEndpoint) + require.True(t, cfg.OTelEnabled()) }) t.Run("log path is captured and validated as writable", func(t *testing.T) { @@ -411,7 +432,7 @@ func clearEnv(t *testing.T) { envCAPath, envCertFile, envKeyFile, envEnableWriteQueries, envQueryTimeout, envMaxRowsCount, envTransport, envHTTPListenAddr, envBearerToken, envTLSCert, envTLSKey, envAllowInsecureHTTP, envAllowPasswordAuth, - envLogLevel, envLogPath, + envLogLevel, envLogPath, envOTelFile, envOTLPEndpoint, } { t.Setenv(k, "") } diff --git a/db/adapter.go b/db/adapter.go index b8eb4c0..4be1ea2 100644 --- a/db/adapter.go +++ b/db/adapter.go @@ -2,13 +2,28 @@ package db import ( "context" + "strings" "time" + mcpotel "github.com/cockroachdb/cockroachdb-mcp-server/otel" + crdbparser "github.com/cockroachdb/cockroachdb-parser/pkg/sql/parser" + "github.com/cockroachdb/cockroachdb-parser/pkg/sql/sem/tree" "github.com/cockroachdb/errors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + semconv "go.opentelemetry.io/otel/semconv/v1.30.0" + "go.opentelemetry.io/otel/trace" ) +const redactedSQLFallback = "" + +// tracerScope names the instrumentation scope for spans created by this +// package. +const tracerScope = "github.com/cockroachdb/cockroachdb-mcp-server/db" + const ( defaultApplicationName = "cockroachdb-mcp-server" // defaultTxnQoS is applied when neither cfg.TxnQoS nor the DSN specifies @@ -134,10 +149,12 @@ func (a *Adapter) Close() { } // Query executes a query and returns columns and rows. -func (a *Adapter) Query(ctx context.Context, sql string) (*QueryResult, error) { +func (a *Adapter) Query(ctx context.Context, sql string) (_ *QueryResult, err error) { if sql == "" { return nil, errors.New("SQL statement cannot be empty") } + ctx, span := startSQLSpan(ctx, sql) + defer func() { endSpan(span, err) }() if a.queryTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, a.queryTimeout) @@ -148,15 +165,22 @@ func (a *Adapter) Query(ctx context.Context, sql string) (*QueryResult, error) { return nil, errors.Wrap(err, "exec query") } defer rows.Close() - return scanRows(rows) + result, err := scanRows(rows) + if err != nil { + return nil, err + } + span.SetAttributes(semconv.DBResponseReturnedRows(len(result.Rows))) + return result, nil } // Exec runs a non-result-returning statement (DDL/DML) and returns the // number of rows affected. -func (a *Adapter) Exec(ctx context.Context, sql string) (int64, error) { +func (a *Adapter) Exec(ctx context.Context, sql string) (_ int64, err error) { if sql == "" { return 0, errors.New("SQL statement cannot be empty") } + ctx, span := startSQLSpan(ctx, sql) + defer func() { endSpan(span, err) }() if a.queryTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, a.queryTimeout) @@ -166,9 +190,71 @@ func (a *Adapter) Exec(ctx context.Context, sql string) (int64, error) { if err != nil { return 0, errors.Wrap(err, "exec statement") } + span.SetAttributes(attribute.Int64("db.response.affected_rows", tag.RowsAffected())) return tag.RowsAffected(), nil } +// startSQLSpan is a no-op until otel.Setup installs an exporter. When no +// exporter is configured, redactSQL is not called so the parser does not run +// on the hot path. +func startSQLSpan(ctx context.Context, sql string) (context.Context, trace.Span) { + op := sqlOperation(sql) + name := "sql.statement" + if op != "" { + name = "sql." + op + } + ctx, span := otel.Tracer(tracerScope).Start(ctx, name, + trace.WithSpanKind(trace.SpanKindClient)) + if !span.IsRecording() { + return ctx, span + } + attrs := []attribute.KeyValue{ + semconv.DBSystemNameCockroachdb, + semconv.DBQueryText(redactSQL(sql)), + } + if op != "" { + attrs = append(attrs, semconv.DBOperationName(op)) + } + span.SetAttributes(attrs...) + return ctx, span +} + +// redactSQL parses sql and re-formats it with table/column names anonymized +// and constants hidden so no user data lands in span attributes. +func redactSQL(sql string) string { + stmts, err := crdbparser.Parse(sql) + if err != nil { + return redactedSQLFallback + } + return stmts.StringWithFlags(tree.FmtAnonymize | tree.FmtHideConstants) +} + +func sqlOperation(sql string) string { + fs := strings.Fields(sql) + if len(fs) == 0 { + return "" + } + op := strings.ToUpper(fs[0]) + for _, r := range op { + if r < 'A' || r > 'Z' { + return "" + } + } + return op +} + +// endSpan must run inside a closure so it captures err at defer-time: +// +// defer func() { endSpan(span, retErr) }() +func endSpan(span trace.Span, err error) { + if err != nil { + safe := mcpotel.SafeSpanError(err) + span.RecordError(safe) + span.SetStatus(codes.Error, safe.Error()) + } + span.End() +} + func scanRows(rows pgx.Rows) (*QueryResult, error) { fds := rows.FieldDescriptions() columns := make([]string, len(fds)) diff --git a/db/adapter_test.go b/db/adapter_test.go index c45bca6..8a8b294 100644 --- a/db/adapter_test.go +++ b/db/adapter_test.go @@ -120,6 +120,63 @@ func TestBuildPoolConfig(t *testing.T) { }) } +func TestRedactSQL(t *testing.T) { + cases := []struct { + name, sql, want string + }{ + { + "SELECT hides literals and identifiers", + "SELECT * FROM system.descriptor WHERE id = 1", + "SELECT * FROM _._ WHERE _ = _", + }, + { + "literal PII in WHERE is scrubbed", + "SELECT email FROM users WHERE email = 'alice@example.com'", + "SELECT _ FROM _ WHERE _ = '_'", + }, + { + "INSERT VALUES payload is scrubbed", + "INSERT INTO mcp_smoke.trace_notes (id, body) VALUES (1, 'reconnect ok')", + "INSERT INTO _._(_, _) VALUES (_, '_')", + }, + { + "CREATE TABLE hides names but preserves types", + "CREATE TABLE t (id INT PRIMARY KEY, name STRING)", + "CREATE TABLE _ (_ INT8 PRIMARY KEY, _ STRING)", + }, + { + "unparseable SQL returns fallback without leaking source", + "not sql at all", + redactedSQLFallback, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, redactSQL(tc.sql)) + }) + } +} + +func TestSQLOperation(t *testing.T) { + cases := []struct { + name, sql, want string + }{ + {"select lowercased", "select 1", "SELECT"}, + {"select uppercased", "SELECT 1", "SELECT"}, + {"leading whitespace", " \n\tSELECT 1", "SELECT"}, + {"multiword DML", "INSERT INTO t VALUES (1)", "INSERT"}, + {"comment-prefixed SQL falls back to empty so span name becomes sql.statement", "-- hello\nSELECT 1", ""}, + {"block-comment-prefixed SQL falls back to empty", "/* c */ SELECT 1", ""}, + {"empty string", "", ""}, + {"whitespace only", " \n\t", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, sqlOperation(tc.sql)) + }) + } +} + // clearPGEnv unsets libpq fallback env vars so a developer's shell // (e.g. an exported PGPASSWORD) cannot leak into the test connection // and mask or flip the password-auth assertions. diff --git a/go.mod b/go.mod index afbe3d1..ef36737 100644 --- a/go.mod +++ b/go.mod @@ -5,18 +5,29 @@ go 1.25.0 require ( github.com/cockroachdb/cockroachdb-parser v0.25.2 github.com/cockroachdb/errors v1.11.3 - github.com/jackc/pgx/v5 v5.7.6 + github.com/jackc/pgx/v5 v5.9.2 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/bridges/otelzap v0.19.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 + go.opentelemetry.io/otel/log v0.20.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/sdk/log v0.20.0 + go.opentelemetry.io/otel/trace v1.44.0 go.uber.org/zap v1.28.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 ) require ( github.com/bazelbuild/rules_go v0.46.0 // indirect github.com/biogo/store v0.0.0-20201120204734-aad293a2328f // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cockroachdb/apd/v3 v3.1.0 // indirect github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect github.com/cockroachdb/redact v1.1.5 // indirect @@ -26,7 +37,7 @@ require ( github.com/dustin/go-humanize v1.0.0 // indirect github.com/elastic/gosigar v0.14.3 // indirect github.com/getsentry/sentry-go v0.27.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect @@ -35,7 +46,7 @@ require ( github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -48,7 +59,7 @@ require ( github.com/pierrre/geohash v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -56,27 +67,22 @@ require ( github.com/twpayne/go-geom v1.4.1 // indirect github.com/twpayne/go-kml v1.5.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 // indirect go.opentelemetry.io/otel/exporters/zipkin v1.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/sdk v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect - go.opentelemetry.io/proto/otlp v1.6.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.42.0 // indirect - gonum.org/v1/gonum v0.16.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/grpc v1.72.1 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4bf0424..20101da 100644 --- a/go.sum +++ b/go.sum @@ -52,10 +52,12 @@ github.com/broady/gogeohash v0.0.0-20120525094510-7b2c40d64042 h1:iEdmkrNMLXbM7e github.com/broady/gogeohash v0.0.0-20120525094510-7b2c40d64042/go.mod h1:f1L9YvXvlt9JTa+A17trQjSMM6bV40f+tHjB+Pi+Fqk= github.com/bsm/sarama-cluster v2.1.13+incompatible/go.mod h1:r7ao+4tTNXvWm+VRpRJchr2kQhqxgmAp2iEX5W96gMM= github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= -github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cockroachdb/apd/v3 v3.1.0 h1:MK3Ow7LH0W8zkd5GMKA1PvS9qG3bWFI95WaVNfyZJ/w= @@ -125,8 +127,8 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= @@ -252,8 +254,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t github.com/grpc-ecosystem/grpc-gateway v1.13.0/go.mod h1:8XEsbTttt/W+VvjtQhLACqCisSPWTxCZ7sBRjU6iH9c= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= github.com/hashicorp/go-hclog v0.14.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= @@ -271,8 +273,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= -github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jaegertracing/jaeger v1.18.1 h1:eFqjEpTKq2FfiZ/YX53oxeCePdIZyWvDfXaTAGj0r5E= @@ -408,8 +410,8 @@ github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= @@ -486,26 +488,42 @@ go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qL go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.3.0/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelzap v0.19.0 h1:48Eq3xxFx2KlL/tF7lnl42kKJBDlhNTLRzv0h154JnM= +go.opentelemetry.io/contrib/bridges/otelzap v0.19.0/go.mod h1:cQbV77F0u6HmtZPiQD9oxp2esaOEb4uLqIta6OFIKOk= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 h1:JgtbA0xkWHnTmYk7YusopJFX6uleBmAuZ8n05NEh8nQ= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= go.opentelemetry.io/otel/exporters/zipkin v1.36.0 h1:s0n95ya5tOG03exJ5JySOdJFtwGo4ZQ+KeY7Zro4CLI= go.opentelemetry.io/otel/exporters/zipkin v1.36.0/go.mod h1:m9wRxtKA2MZ1HcnNC4BKI+9aYe434qRZTCvI7QGUN7Y= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= -go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= +go.opentelemetry.io/otel/log/logtest v0.20.0 h1:+tsZVE15N+RWyN9lUzsRyw7hMZXNMepGu105Eim82/k= +go.opentelemetry.io/otel/log/logtest v0.20.0/go.mod h1:zS9Ryx9RrEAG2tgapMBSvacwhVSSOGSaSiWWgW3NPlQ= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= +go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -515,8 +533,8 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= @@ -537,8 +555,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 h1:/RIbNt/Zr7rVhIkQhooTxCxFcdWLGIKnZA4IXNFSrvo= golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= @@ -552,8 +568,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -577,12 +593,12 @@ golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -591,8 +607,8 @@ golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -616,13 +632,13 @@ golang.org/x/sys v0.0.0-20200121082415-34d275377bf9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -651,14 +667,14 @@ golang.org/x/tools v0.0.0-20200203023011-6f24f261dadb/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -678,10 +694,10 @@ google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index a2bc116..842a3bc 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/cockroachdb/cockroachdb-mcp-server/db" "github.com/cockroachdb/cockroachdb-mcp-server/logging" "github.com/cockroachdb/cockroachdb-mcp-server/middleware" + mcpotel "github.com/cockroachdb/cockroachdb-mcp-server/otel" "github.com/cockroachdb/cockroachdb-mcp-server/tools" "github.com/cockroachdb/errors" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -77,6 +78,21 @@ func run() error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + shutdownOTel, err := mcpotel.Setup(ctx, cfg, serverName, serverVersion) + if err != nil { + return errors.Wrap(err, "setup opentelemetry") + } + defer func() { + sctx, cancel := context.WithTimeout(context.Background(), shutdownPeriod) + defer cancel() + if err := shutdownOTel(sctx); err != nil { + zap.L().Warn("opentelemetry shutdown", zap.Error(err)) + } + }() + if cfg.OTelEnabled() { + zap.ReplaceGlobals(mcpotel.AttachZapBridge(logger, serverName)) + } + dm, err := db.NewManager(ctx, cfg) if err != nil { return errors.Wrap(err, "initialize database manager") @@ -87,7 +103,7 @@ func run() error { Name: serverName, Version: serverVersion, }, nil) - server.AddReceivingMiddleware(middleware.ToolCallLogger) + server.AddReceivingMiddleware(middleware.ToolCallSpan, middleware.ToolCallLogger) tools.NewToolHandlers(dm, cfg).RegisterTools(server) zap.L().Info("starting server", diff --git a/middleware/otel.go b/middleware/otel.go new file mode 100644 index 0000000..6e1ade9 --- /dev/null +++ b/middleware/otel.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "context" + + mcpotel "github.com/cockroachdb/cockroachdb-mcp-server/otel" + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// tracerScope names the instrumentation scope for spans created by this +// package. +const tracerScope = "github.com/cockroachdb/cockroachdb-mcp-server/middleware" + +// ToolCallSpan wraps every tools/call in an OTel span named after the tool. +func ToolCallSpan(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method != methodCallTool { + return next(ctx, method, req) + } + name := "unknown_tool" + if p, ok := req.GetParams().(*mcp.CallToolParamsRaw); ok && p.Name != "" { + name = p.Name + } + ctx, span := otel.Tracer(tracerScope).Start(ctx, name, + trace.WithSpanKind(trace.SpanKindServer)) + defer span.End() + result, err := next(ctx, method, req) + switch { + case err != nil: + safe := mcpotel.SafeSpanError(err) + span.RecordError(safe) + span.SetStatus(codes.Error, safe.Error()) + case isErrorResult(result): + span.SetStatus(codes.Error, "tool returned error result") + } + return result, err + } +} diff --git a/middleware/otel_test.go b/middleware/otel_test.go new file mode 100644 index 0000000..cb07bdd --- /dev/null +++ b/middleware/otel_test.go @@ -0,0 +1,129 @@ +package middleware + +import ( + "context" + "testing" + + "github.com/cockroachdb/errors" + "github.com/jackc/pgx/v5/pgconn" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestToolCallSpan(t *testing.T) { + const toolName = "fake_tool" + + withRecorder := func(t *testing.T) *tracetest.SpanRecorder { + t.Helper() + rec := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec)) + prev := otel.GetTracerProvider() + otel.SetTracerProvider(tp) + t.Cleanup(func() { otel.SetTracerProvider(prev) }) + return rec + } + + callToolReq := func() mcp.Request { + return &mcp.ServerRequest[*mcp.CallToolParamsRaw]{Params: &mcp.CallToolParamsRaw{Name: toolName}} + } + + t.Run("successful call records a span named after the tool", func(t *testing.T) { + rec := withRecorder(t) + next := func(context.Context, string, mcp.Request) (mcp.Result, error) { + return &mcp.CallToolResult{}, nil + } + _, err := ToolCallSpan(next)(context.Background(), methodCallTool, callToolReq()) + require.NoError(t, err) + + spans := rec.Ended() + require.Len(t, spans, 1) + require.Equal(t, toolName, spans[0].Name()) + require.Equal(t, "Unset", spans[0].Status().Code.String()) + require.Equal(t, "server", spans[0].SpanKind().String()) + }) + + t.Run("transport error sets Error status and records the error", func(t *testing.T) { + rec := withRecorder(t) + boom := errors.New("boom") + next := func(context.Context, string, mcp.Request) (mcp.Result, error) { + return nil, boom + } + _, err := ToolCallSpan(next)(context.Background(), methodCallTool, callToolReq()) + require.ErrorIs(t, err, boom) + + spans := rec.Ended() + require.Len(t, spans, 1) + require.Equal(t, "Error", spans[0].Status().Code.String()) + require.NotEmpty(t, spans[0].Events(), "RecordError should have added an event") + }) + + t.Run("empty tool name falls back to unknown_tool", func(t *testing.T) { + rec := withRecorder(t) + next := func(context.Context, string, mcp.Request) (mcp.Result, error) { + return &mcp.CallToolResult{}, nil + } + req := &mcp.ServerRequest[*mcp.CallToolParamsRaw]{Params: &mcp.CallToolParamsRaw{}} + _, err := ToolCallSpan(next)(context.Background(), methodCallTool, req) + require.NoError(t, err) + + spans := rec.Ended() + require.Len(t, spans, 1) + require.Equal(t, "unknown_tool", spans[0].Name()) + }) + + t.Run("database error is sanitized before recording on the span", func(t *testing.T) { + rec := withRecorder(t) + pgErr := &pgconn.PgError{ + Code: "42P01", + Message: `relation "secret_users" does not exist`, + Where: `SELECT * FROM secret_users WHERE ssn = '123-45-6789'`, + } + wrapped := errors.Wrap(pgErr, "list databases") + next := func(context.Context, string, mcp.Request) (mcp.Result, error) { + return nil, wrapped + } + _, err := ToolCallSpan(next)(context.Background(), methodCallTool, callToolReq()) + require.ErrorIs(t, err, wrapped) + + spans := rec.Ended() + require.Len(t, spans, 1) + require.Equal(t, "Error", spans[0].Status().Code.String()) + require.Contains(t, spans[0].Status().Description, "sqlstate 42P01") + require.NotContains(t, spans[0].Status().Description, "secret_users") + events := spans[0].Events() + require.NotEmpty(t, events) + for _, ev := range events { + for _, attr := range ev.Attributes { + require.NotContains(t, attr.Value.AsString(), "secret_users") + require.NotContains(t, attr.Value.AsString(), "123-45-6789") + } + } + }) + + t.Run("CallToolResult.IsError sets Error status without an event", func(t *testing.T) { + rec := withRecorder(t) + next := func(context.Context, string, mcp.Request) (mcp.Result, error) { + return &mcp.CallToolResult{IsError: true}, nil + } + _, err := ToolCallSpan(next)(context.Background(), methodCallTool, callToolReq()) + require.NoError(t, err) + + spans := rec.Ended() + require.Len(t, spans, 1) + require.Equal(t, "Error", spans[0].Status().Code.String()) + }) + + t.Run("non-tool method bypasses span creation", func(t *testing.T) { + rec := withRecorder(t) + next := func(context.Context, string, mcp.Request) (mcp.Result, error) { + return nil, nil + } + _, err := ToolCallSpan(next)(context.Background(), "notifications/initialized", + &mcp.ServerRequest[*mcp.CallToolParamsRaw]{Params: &mcp.CallToolParamsRaw{}}) + require.NoError(t, err) + require.Empty(t, rec.Ended()) + }) +} diff --git a/otel/otel.go b/otel/otel.go new file mode 100644 index 0000000..c4ca852 --- /dev/null +++ b/otel/otel.go @@ -0,0 +1,144 @@ +// Package otel wires OpenTelemetry traces and logs for the MCP server. +// +// OTel is opt-in: if neither CRDB_MCP_OTEL_FILE nor OTEL_EXPORTER_OTLP_ENDPOINT +// is set, Setup installs no exporters and the global providers stay no-op. +package otel + +import ( + "context" + "os" + + "github.com/cockroachdb/cockroachdb-mcp-server/config" + "github.com/cockroachdb/errors" + "github.com/jackc/pgx/v5/pgconn" + "go.opentelemetry.io/contrib/bridges/otelzap" + otelapi "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/stdout/stdoutlog" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/log/global" + sdklog "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.30.0" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// SafeSpanError strips pgx error Message/Detail/Where fields, which can echo +// the offending SQL and user data into span events and status descriptions, +// undermining the query-text redaction applied to span attributes. +func SafeSpanError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return errors.Newf("cockroachdb error: sqlstate %s", pgErr.Code) + } + return err +} + +// ShutdownFunc flushes and closes any registered exporters. +type ShutdownFunc func(context.Context) error + +// Setup installs trace and log providers based on cfg: +// - cfg.OTelFile set: stdout exporters write JSON to that file (good for +// local dev or file-based shippers like Filebeat, Vector). +// - cfg.OTLPEndpoint set: OTLP gRPC exporters honor the standard OTEL_* +// env vars (Datadog Agent, OTel Collector, Tempo, Honeycomb, ELK). +// - neither set: returns a no-op shutdown and the global providers stay +// no-op. +// +// File mode takes precedence when both are configured. +func Setup( + ctx context.Context, cfg *config.Config, serviceName, serviceVersion string, +) (ShutdownFunc, error) { + if !cfg.OTelEnabled() { + return noopShutdown, nil + } + + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(serviceName), + semconv.ServiceVersion(serviceVersion), + ), + resource.WithFromEnv(), + resource.WithHost(), + resource.WithProcessRuntimeName(), + ) + if err != nil { + return nil, errors.Wrap(err, "build otel resource") + } + + traceExp, logExp, fileCloser, err := newExporters(ctx, cfg.OTelFile) + if err != nil { + return nil, err + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExp), + sdktrace.WithResource(res), + ) + otelapi.SetTracerProvider(tp) + + lp := sdklog.NewLoggerProvider( + sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)), + sdklog.WithResource(res), + ) + global.SetLoggerProvider(lp) + + return func(ctx context.Context) error { + tperr := tp.Shutdown(ctx) + lperr := lp.Shutdown(ctx) + var fcerr error + if fileCloser != nil { + fcerr = fileCloser.Close() + } + return errors.Join(tperr, lperr, fcerr) + }, nil +} + +// AttachZapBridge wraps logger so each record is also exported as an OTel log +// record via the global LoggerProvider. Call only after Setup returned a +// non-nil shutdown (i.e. an exporter is actually configured). +func AttachZapBridge(logger *zap.Logger, serviceName string) *zap.Logger { + bridge := otelzap.NewCore(serviceName, + otelzap.WithLoggerProvider(global.GetLoggerProvider())) + return logger.WithOptions(zap.WrapCore(func(c zapcore.Core) zapcore.Core { + return zapcore.NewTee(c, bridge) + })) +} + +func newExporters( + ctx context.Context, filePath string, +) (sdktrace.SpanExporter, sdklog.Exporter, *os.File, error) { + if filePath != "" { + f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, nil, nil, errors.Wrapf(err, "open otel file %q", filePath) + } + te, err := stdouttrace.New(stdouttrace.WithWriter(f)) + if err != nil { + _ = f.Close() + return nil, nil, nil, errors.Wrap(err, "build stdout trace exporter") + } + le, err := stdoutlog.New(stdoutlog.WithWriter(f)) + if err != nil { + _ = te.Shutdown(ctx) + _ = f.Close() + return nil, nil, nil, errors.Wrap(err, "build stdout log exporter") + } + return te, le, f, nil + } + te, err := otlptracegrpc.New(ctx) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "build otlp trace exporter") + } + le, err := otlploggrpc.New(ctx) + if err != nil { + _ = te.Shutdown(ctx) + return nil, nil, nil, errors.Wrap(err, "build otlp log exporter") + } + return te, le, nil, nil +} + +func noopShutdown(context.Context) error { return nil } diff --git a/otel/otel_test.go b/otel/otel_test.go new file mode 100644 index 0000000..820717d --- /dev/null +++ b/otel/otel_test.go @@ -0,0 +1,111 @@ +package otel + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cockroachdb/cockroachdb-mcp-server/config" + "github.com/cockroachdb/cockroachdb-mcp-server/logging" + "github.com/cockroachdb/errors" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" + otelapi "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/log/global" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestSetup(t *testing.T) { + t.Run("no exporter configured returns noop shutdown", func(t *testing.T) { + ctx := context.Background() + shutdown, err := Setup(ctx, &config.Config{}, "svc", "v0") + require.NoError(t, err) + require.NotNil(t, shutdown) + require.NoError(t, shutdown(ctx)) + }) + + restoreGlobals := func(t *testing.T) { + t.Helper() + prevTP := otelapi.GetTracerProvider() + prevLP := global.GetLoggerProvider() + t.Cleanup(func() { + otelapi.SetTracerProvider(prevTP) + global.SetLoggerProvider(prevLP) + }) + } + + t.Run("file exporter writes spans as JSON", func(t *testing.T) { + ctx := context.Background() + restoreGlobals(t) + path := filepath.Join(t.TempDir(), "otel.jsonl") + cfg := &config.Config{OTelFile: path} + + shutdown, err := Setup(ctx, cfg, "test-service", "v1.2.3") + require.NoError(t, err) + + _, span := otelapi.Tracer("test").Start(ctx, "unit-span") + span.End() + + require.NoError(t, shutdown(ctx)) + + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.Greater(t, len(raw), 0, "expected exporter to write at least one span") + + var span0 map[string]any + dec := json.NewDecoder(strings.NewReader(string(raw))) + require.NoError(t, dec.Decode(&span0)) + require.Equal(t, "unit-span", span0["Name"]) + }) + + t.Run("missing parent dir surfaces a wrapped error", func(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{OTelFile: "/nonexistent/dir/otel.jsonl"} + _, err := Setup(ctx, cfg, "svc", "v0") + require.Error(t, err) + require.Contains(t, err.Error(), "open otel file") + }) +} + +func TestSafeSpanError(t *testing.T) { + t.Run("pgx error is reduced to sqlstate", func(t *testing.T) { + pgErr := &pgconn.PgError{ + Code: "42P01", + Message: `relation "secret_users" does not exist`, + Where: `SELECT * FROM secret_users WHERE ssn = '123-45-6789'`, + } + wrapped := errors.Wrap(pgErr, "exec query") + safe := SafeSpanError(wrapped) + require.Contains(t, safe.Error(), "sqlstate 42P01") + require.NotContains(t, safe.Error(), "secret_users") + require.NotContains(t, safe.Error(), "123-45-6789") + }) + + t.Run("non-pgx error passes through unchanged", func(t *testing.T) { + raw := errors.New("connection reset by peer") + require.Same(t, raw, SafeSpanError(raw)) + }) +} + +func TestAttachZapBridge(t *testing.T) { + t.Run("returns a logger that still emits at requested level", func(t *testing.T) { + base, err := logging.NewLogger(zapcore.InfoLevel, "") + require.NoError(t, err) + wrapped := AttachZapBridge(base, "test-service") + require.NotNil(t, wrapped) + require.True(t, wrapped.Core().Enabled(zapcore.InfoLevel)) + }) + + t.Run("bridge does not panic without provider configured", func(t *testing.T) { + base, err := logging.NewLogger(zapcore.InfoLevel, "") + require.NoError(t, err) + wrapped := AttachZapBridge(base, "test-service") + require.NotPanics(t, func() { + wrapped.Info("hello", zap.String("k", "v")) + }) + }) +}