From 5ad227e36ca5116112a3f28b30950a03183c2db7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 20:44:11 +0000 Subject: [PATCH 1/3] feat(telemetry): add opt-in user.id span attribution Add an optional, default-off telemetry feature that sets the OTEL standard "user.id" span attribute on the inbound MCP server span, sourced from the authenticated subject (auth.Identity.Subject). When disabled (the default) behavior is unchanged: no user attribution lands on any span. When enabled, "user.id" is emitted only when an authenticated identity is present on the request context, so anonymous requests are unaffected. The attribute is high-cardinality and is never added to any metric instrument. Default-off because the subject can be personally- or tenant-identifying. Plumbs the toggle through every layer: - telemetry.Config.EnableUserIDAttribute (consumed by NewHTTPMiddleware) - addMCPAttributes reads auth.IdentityFromContext when enabled - CLI flag --otel-enable-user-id-attribute (mirrors --otel-use-legacy-attributes) with config-file fallback - app config OpenTelemetryConfig.EnableUserIDAttribute and the shared BuildTelemetryConfigFromAppConfig / MaybeMakeConfig builders - operator MCPTelemetryConfig CRD field + spectoconfig conversion Tests cover off (no attribute even with identity), on with subject (attribute set), and on without subject / anonymous (no attribute), both directly and through addMCPAttributes. Regenerated CRD manifests, helm templates, CRD-API and swagger docs; documented the attribute, its opt-in nature, and the PII consideration in docs/observability.md. Closes #5455 --- .../api/v1beta1/mcptelemetryconfig_types.go | 9 ++ .../pkg/spectoconfig/telemetry.go | 1 + .../pkg/spectoconfig/telemetry_drift_test.go | 1 + cmd/thv/app/run_flags.go | 24 +++- cmd/thv/app/run_flags_test.go | 6 +- ...hive.stacklok.dev_mcptelemetryconfigs.yaml | 18 +++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 28 ++++ ...hive.stacklok.dev_mcptelemetryconfigs.yaml | 18 +++ ...olhive.stacklok.dev_virtualmcpservers.yaml | 28 ++++ docs/observability.md | 27 ++++ docs/operator/crd-api.md | 2 + docs/server/docs.go | 4 + docs/server/swagger.json | 4 + docs/server/swagger.yaml | 15 ++ pkg/config/config.go | 1 + pkg/runner/config_builder.go | 2 + pkg/runner/config_test.go | 16 +-- pkg/runner/telemetry_config.go | 1 + pkg/telemetry/config.go | 16 +++ pkg/telemetry/middleware.go | 27 ++++ pkg/telemetry/middleware_test.go | 134 ++++++++++++++++++ 21 files changed, 368 insertions(+), 14 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go b/cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go index c35eb73de2..935c0bc138 100644 --- a/cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go @@ -92,6 +92,15 @@ type MCPTelemetryOTelConfig struct { // +optional UseLegacyAttributes bool `json:"useLegacyAttributes"` + // EnableUserIDAttribute controls whether the authenticated subject is emitted as the + // OTEL "user.id" span attribute on the inbound MCP server span. Defaults to false because + // the subject can be personally- or tenant-identifying. When enabled, the attribute is only + // set when an authenticated identity is present on the request context, so anonymous requests + // are unaffected. The attribute is high-cardinality and is never added to any metric instrument. + // +kubebuilder:default=false + // +optional + EnableUserIDAttribute bool `json:"enableUserIDAttribute,omitempty"` + // CABundleRef references a ConfigMap containing a CA certificate bundle for the OTLP endpoint. // When specified, the operator mounts the ConfigMap into the proxyrunner pod and configures // the OTLP exporters to trust the custom CA. This is useful when the OTLP collector uses diff --git a/cmd/thv-operator/pkg/spectoconfig/telemetry.go b/cmd/thv-operator/pkg/spectoconfig/telemetry.go index 3fd554e66c..3d324e5eec 100644 --- a/cmd/thv-operator/pkg/spectoconfig/telemetry.go +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry.go @@ -36,6 +36,7 @@ func NormalizeMCPTelemetryConfig( config.Headers = otel.Headers config.CustomAttributes = otel.ResourceAttributes config.UseLegacyAttributes = otel.UseLegacyAttributes + config.EnableUserIDAttribute = otel.EnableUserIDAttribute if otel.Tracing != nil { config.TracingEnabled = otel.Tracing.Enabled diff --git a/cmd/thv-operator/pkg/spectoconfig/telemetry_drift_test.go b/cmd/thv-operator/pkg/spectoconfig/telemetry_drift_test.go index acfff97d1b..02b8c01cfa 100644 --- a/cmd/thv-operator/pkg/spectoconfig/telemetry_drift_test.go +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry_drift_test.go @@ -44,6 +44,7 @@ var telemetryFieldMappings = []testutil.FieldMapping{ {CRD: "openTelemetry.tracing.samplingRate", Runtime: "samplingRate"}, {CRD: "openTelemetry.metrics.enabled", Runtime: "metricsEnabled"}, {CRD: "openTelemetry.useLegacyAttributes", Runtime: "useLegacyAttributes"}, + {CRD: "openTelemetry.enableUserIDAttribute", Runtime: "enableUserIDAttribute"}, {CRD: "prometheus.enabled", Runtime: "enablePrometheusMetricsPath"}, } diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index f4f998e9dd..94185a4735 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -94,6 +94,7 @@ type RunFlags struct { OtelEnvironmentVariables []string // renamed binding to otel-env-vars OtelCustomAttributes string // Custom attributes in key=value format OtelUseLegacyAttributes bool // Emit legacy attribute names alongside new ones + OtelEnableUserIDAttribute bool // Emit authenticated subject as the user.id span attribute // Network isolation IsolateNetwork bool @@ -256,6 +257,9 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) { "Custom resource attributes for OpenTelemetry in key=value format (e.g., server_type=prod,region=us-east-1,team=platform)") cmd.Flags().BoolVar(&config.OtelUseLegacyAttributes, "otel-use-legacy-attributes", true, "Emit legacy attribute names alongside new OTEL semantic convention names (default true)") + cmd.Flags().BoolVar(&config.OtelEnableUserIDAttribute, "otel-enable-user-id-attribute", false, + "Emit the authenticated subject as the user.id span attribute on the MCP server span "+ + "(default false; may expose personally- or tenant-identifying data)") cmd.Flags().BoolVar(&config.IsolateNetwork, "isolate-network", true, "Isolate the container network from the host. Use --isolate-network=false to opt out.") @@ -422,13 +426,14 @@ func setupTelemetryConfiguration(cmd *cobra.Command, runFlags *RunFlags, appConf cmd, appConfig, runFlags.OtelEndpoint, runFlags.OtelSamplingRate, runFlags.OtelEnvironmentVariables, runFlags.OtelInsecure, runFlags.OtelEnablePrometheusMetricsPath, runFlags.OtelUseLegacyAttributes, + runFlags.OtelEnableUserIDAttribute, runFlags.OtelTracingEnabled, runFlags.OtelMetricsEnabled) return createTelemetryConfig(finalTelemetry.OtelEndpoint, finalTelemetry.OtelEnablePrometheusMetricsPath, runFlags.OtelServiceName, finalTelemetry.OtelTracingEnabled, finalTelemetry.OtelMetricsEnabled, finalTelemetry.OtelSamplingRate, runFlags.OtelHeaders, finalTelemetry.OtelInsecure, finalTelemetry.OtelEnvironmentVariables, runFlags.OtelCustomAttributes, - finalTelemetry.OtelUseLegacyAttributes) + finalTelemetry.OtelUseLegacyAttributes, finalTelemetry.OtelEnableUserIDAttribute) } // setupRuntimeAndValidation creates container runtime and selects environment variable validator. @@ -819,7 +824,7 @@ func configureMiddlewareAndOptions( runner.WithTelemetryConfigFromFlags(finalOtelEndpoint, runFlags.OtelEnablePrometheusMetricsPath, finalTracingEnabled, finalMetricsEnabled, runFlags.OtelServiceName, finalOtelSamplingRate, runFlags.OtelHeaders, runFlags.OtelInsecure, finalOtelEnvironmentVariables, - runFlags.OtelUseLegacyAttributes, + runFlags.OtelUseLegacyAttributes, runFlags.OtelEnableUserIDAttribute, ), runner.WithToolsFilter(runFlags.ToolsFilter)) @@ -1068,6 +1073,7 @@ type finalTelemetry struct { OtelInsecure bool OtelEnablePrometheusMetricsPath bool OtelUseLegacyAttributes bool + OtelEnableUserIDAttribute bool OtelTracingEnabled bool OtelMetricsEnabled bool } @@ -1075,7 +1081,8 @@ type finalTelemetry struct { // getTelemetryFromFlags extracts telemetry configuration from command flags func getTelemetryFromFlags(cmd *cobra.Command, config *cfg.Config, otelEndpoint string, otelSamplingRate float64, otelEnvironmentVariables []string, otelInsecure bool, otelEnablePrometheusMetricsPath bool, - otelUseLegacyAttributes bool, otelTracingEnabled bool, otelMetricsEnabled bool) finalTelemetry { + otelUseLegacyAttributes bool, otelEnableUserIDAttribute bool, + otelTracingEnabled bool, otelMetricsEnabled bool) finalTelemetry { // Use config values as fallbacks for OTEL flags if not explicitly set finalOtelEndpoint := otelEndpoint if !cmd.Flags().Changed("otel-endpoint") && config.OTEL.Endpoint != "" { @@ -1121,6 +1128,13 @@ func getTelemetryFromFlags(cmd *cobra.Command, config *cfg.Config, otelEndpoint finalOtelUseLegacyAttributes = *config.OTEL.UseLegacyAttributes } + // EnableUserIDAttribute defaults to false (opt-in). The config value is used + // as a fallback only when the CLI flag was not explicitly set. + finalOtelEnableUserIDAttribute := otelEnableUserIDAttribute + if !cmd.Flags().Changed("otel-enable-user-id-attribute") { + finalOtelEnableUserIDAttribute = config.OTEL.EnableUserIDAttribute + } + return finalTelemetry{ OtelEndpoint: finalOtelEndpoint, OtelSamplingRate: finalOtelSamplingRate, @@ -1128,6 +1142,7 @@ func getTelemetryFromFlags(cmd *cobra.Command, config *cfg.Config, otelEndpoint OtelInsecure: finalOtelInsecure, OtelEnablePrometheusMetricsPath: finalOtelEnablePrometheusMetricsPath, OtelUseLegacyAttributes: finalOtelUseLegacyAttributes, + OtelEnableUserIDAttribute: finalOtelEnableUserIDAttribute, OtelTracingEnabled: finalOtelTracingEnabled, OtelMetricsEnabled: finalOtelMetricsEnabled, } @@ -1163,7 +1178,7 @@ func createOIDCConfig(oidcIssuer, oidcAudience, oidcJwksURL, oidcIntrospectionUR func createTelemetryConfig(otelEndpoint string, otelEnablePrometheusMetricsPath bool, otelServiceName string, otelTracingEnabled bool, otelMetricsEnabled bool, otelSamplingRate float64, otelHeaders []string, otelInsecure bool, otelEnvironmentVariables []string, otelCustomAttributes string, - otelUseLegacyAttributes bool) *telemetry.Config { + otelUseLegacyAttributes bool, otelEnableUserIDAttribute bool) *telemetry.Config { return runner.BuildTelemetryConfigFromAppConfig( cfg.OpenTelemetryConfig{ Endpoint: otelEndpoint, @@ -1174,6 +1189,7 @@ func createTelemetryConfig(otelEndpoint string, otelEnablePrometheusMetricsPath Insecure: otelInsecure, EnablePrometheusMetricsPath: otelEnablePrometheusMetricsPath, UseLegacyAttributes: &otelUseLegacyAttributes, + EnableUserIDAttribute: otelEnableUserIDAttribute, }, otelServiceName, otelHeaders, diff --git a/cmd/thv/app/run_flags_test.go b/cmd/thv/app/run_flags_test.go index d93153148d..fb76b97cd9 100644 --- a/cmd/thv/app/run_flags_test.go +++ b/cmd/thv/app/run_flags_test.go @@ -367,6 +367,7 @@ func TestBuildRunnerConfig_TelemetryProcessing(t *testing.T) { tt.runFlags.OtelInsecure, tt.runFlags.OtelEnablePrometheusMetricsPath, tt.runFlags.OtelUseLegacyAttributes, + tt.runFlags.OtelEnableUserIDAttribute, tt.runFlags.OtelTracingEnabled, tt.runFlags.OtelMetricsEnabled, ) @@ -513,6 +514,7 @@ func TestBuildRunnerConfig_TelemetryProcessing_Integration(t *testing.T) { runFlags.OtelInsecure, runFlags.OtelEnablePrometheusMetricsPath, runFlags.OtelUseLegacyAttributes, + runFlags.OtelEnableUserIDAttribute, runFlags.OtelTracingEnabled, runFlags.OtelMetricsEnabled, ) @@ -584,7 +586,7 @@ func TestCreateTelemetryConfig_DisabledSignals(t *testing.T) { result := createTelemetryConfig( tt.endpoint, tt.enablePrometheusMetricsPath, "test-service", tt.tracingEnabled, tt.metricsEnabled, - 1.0, nil, false, nil, "", true, + 1.0, nil, false, nil, "", true, false, ) if tt.expectNil { @@ -682,7 +684,7 @@ func TestSetupTelemetryConfiguration_LoadOrCreateConfigPath(t *testing.T) { result := getTelemetryFromFlags( cmd, appConfig, - "", 0.0, nil, false, false, false, true, true, + "", 0.0, nil, false, false, false, false, true, true, ) assert.Equal(t, "https://provider-endpoint.example.com", result.OtelEndpoint, diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml index 2262801a88..7df166d16b 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml @@ -104,6 +104,15 @@ spec: type: object x-kubernetes-map-type: atomic type: object + enableUserIDAttribute: + default: false + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted as the + OTEL "user.id" span attribute on the inbound MCP server span. Defaults to false because + the subject can be personally- or tenant-identifying. When enabled, the attribute is only + set when an authenticated identity is present on the request context, so anonymous requests + are unaffected. The attribute is high-cardinality and is never added to any metric instrument. + type: boolean enabled: default: false description: Enabled controls whether OpenTelemetry is enabled @@ -405,6 +414,15 @@ spec: type: object x-kubernetes-map-type: atomic type: object + enableUserIDAttribute: + default: false + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted as the + OTEL "user.id" span attribute on the inbound MCP server span. Defaults to false because + the subject can be personally- or tenant-identifying. When enabled, the attribute is only + set when an authenticated identity is present on the request context, so anonymous requests + are unaffected. The attribute is high-cardinality and is never added to any metric instrument. + type: boolean enabled: default: false description: Enabled controls whether OpenTelemetry is enabled diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 676c5cc61a..d2a9ffbe45 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2505,6 +2505,20 @@ spec: The metrics are served on the main transport port at /metrics. This is separate from OTLP metrics which are sent to the Endpoint. type: boolean + enableUserIDAttribute: + default: false + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted + as the OTEL "user.id" span attribute on the inbound MCP server span. + When false (the default), no user attribution lands on spans and behavior is + unchanged. When true, "user.id" is set from the authenticated identity's + Subject only when an identity is present on the request context, so + anonymous requests are unaffected. + + Defaults to false because the subject can be personally- or + tenant-identifying. The attribute is high-cardinality and is intentionally + never added to any metric instrument. + type: boolean endpoint: description: Endpoint is the OTLP endpoint URL type: string @@ -5703,6 +5717,20 @@ spec: The metrics are served on the main transport port at /metrics. This is separate from OTLP metrics which are sent to the Endpoint. type: boolean + enableUserIDAttribute: + default: false + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted + as the OTEL "user.id" span attribute on the inbound MCP server span. + When false (the default), no user attribution lands on spans and behavior is + unchanged. When true, "user.id" is set from the authenticated identity's + Subject only when an identity is present on the request context, so + anonymous requests are unaffected. + + Defaults to false because the subject can be personally- or + tenant-identifying. The attribute is high-cardinality and is intentionally + never added to any metric instrument. + type: boolean endpoint: description: Endpoint is the OTLP endpoint URL type: string diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml index 3377fe0408..d04a0192e4 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml @@ -107,6 +107,15 @@ spec: type: object x-kubernetes-map-type: atomic type: object + enableUserIDAttribute: + default: false + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted as the + OTEL "user.id" span attribute on the inbound MCP server span. Defaults to false because + the subject can be personally- or tenant-identifying. When enabled, the attribute is only + set when an authenticated identity is present on the request context, so anonymous requests + are unaffected. The attribute is high-cardinality and is never added to any metric instrument. + type: boolean enabled: default: false description: Enabled controls whether OpenTelemetry is enabled @@ -408,6 +417,15 @@ spec: type: object x-kubernetes-map-type: atomic type: object + enableUserIDAttribute: + default: false + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted as the + OTEL "user.id" span attribute on the inbound MCP server span. Defaults to false because + the subject can be personally- or tenant-identifying. When enabled, the attribute is only + set when an authenticated identity is present on the request context, so anonymous requests + are unaffected. The attribute is high-cardinality and is never added to any metric instrument. + type: boolean enabled: default: false description: Enabled controls whether OpenTelemetry is enabled diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 3f1d47f1c7..602d9c0faf 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2508,6 +2508,20 @@ spec: The metrics are served on the main transport port at /metrics. This is separate from OTLP metrics which are sent to the Endpoint. type: boolean + enableUserIDAttribute: + default: false + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted + as the OTEL "user.id" span attribute on the inbound MCP server span. + When false (the default), no user attribution lands on spans and behavior is + unchanged. When true, "user.id" is set from the authenticated identity's + Subject only when an identity is present on the request context, so + anonymous requests are unaffected. + + Defaults to false because the subject can be personally- or + tenant-identifying. The attribute is high-cardinality and is intentionally + never added to any metric instrument. + type: boolean endpoint: description: Endpoint is the OTLP endpoint URL type: string @@ -5706,6 +5720,20 @@ spec: The metrics are served on the main transport port at /metrics. This is separate from OTLP metrics which are sent to the Endpoint. type: boolean + enableUserIDAttribute: + default: false + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted + as the OTEL "user.id" span attribute on the inbound MCP server span. + When false (the default), no user attribution lands on spans and behavior is + unchanged. When true, "user.id" is set from the authenticated identity's + Subject only when an identity is present on the request context, so + anonymous requests are unaffected. + + Defaults to false because the subject can be personally- or + tenant-identifying. The attribute is high-cardinality and is intentionally + never added to any metric instrument. + type: boolean endpoint: description: Endpoint is the OTLP endpoint URL type: string diff --git a/docs/observability.md b/docs/observability.md index 4f48062e4b..23a2937443 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -105,6 +105,7 @@ maintaining the modular architecture of ToolHive's middleware system. | `--otel-env-vars` | string[] | `nil` | Environment variables to include in spans (comma-separated) | | `--otel-custom-attributes` | string | `""` | Custom resource attributes (`key1=value1,key2=value2`) | | `--otel-use-legacy-attributes` | bool | `true` | Emit legacy attribute names alongside new OTEL semantic convention names | +| `--otel-enable-user-id-attribute` | bool | `false` | Emit the authenticated subject as the `user.id` span attribute on the MCP server span. Opt-in; may expose personally- or tenant-identifying data | ### Configuration File @@ -119,6 +120,9 @@ otel: - DEPLOYMENT_ENV insecure: true use-legacy-attributes: false + # Opt-in: attach the authenticated subject as the user.id span attribute. + # Default false; see "User Attribution" below for the PII consideration. + enable-user-id-attribute: false ``` CLI flags take precedence over configuration file values when explicitly set. @@ -334,6 +338,29 @@ The `mcp.resource.uri` attribute is set only for the following methods: | `client.address` | string | Remote address available | Client IP address | | `client.port` | int | Port parseable from remote address | Client port | +### User Attribution Attribute (opt-in) + +| Attribute | Type | Condition | Description | +|-----------|------|-----------|-------------| +| `user.id` | string | `enableUserIDAttribute` is true **and** an authenticated identity is present | The authenticated subject (OIDC `sub`) of the caller | + +`user.id` is **opt-in and default-off**. Enable it with the +`--otel-enable-user-id-attribute` CLI flag, the `enable-user-id-attribute` +config-file field, or the `enableUserIDAttribute` field on +`MCPTelemetryConfig` / inline telemetry config. + +When the feature is disabled (the default), no user attribution is added to any +span and behavior is unchanged. When enabled, the attribute is set from the +authenticated identity's `Subject` only when an identity is present on the +request context, so anonymous/unauthenticated requests are unaffected. + +> **Privacy note:** The subject can be personally- or tenant-identifying. For +> this reason the feature defaults to off, and `user.id` is intentionally +> **never** added to any metric instrument (it is high-cardinality). Enable it +> only where capturing per-user attribution in traces is acceptable for your +> privacy posture. Pseudonymization, if desired, belongs in a downstream +> collector/processor. + ### Error Attributes | Attribute | Type | Condition | Description | diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index dda7abcc0a..04f62c0a44 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -886,6 +886,7 @@ _Appears in:_ | `customAttributes` _object (keys:string, values:string)_ | CustomAttributes contains custom resource attributes to be added to all telemetry signals.
These are parsed from CLI flags (--otel-custom-attributes) or environment variables
(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs. | | Optional: \{\}
| | `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy (pre-MCP OTEL semconv) attribute names
are emitted alongside the new standard attribute names. When true, spans include both
old and new attribute names for backward compatibility with existing dashboards.
Currently defaults to true; this will change to false in a future release. | true | Optional: \{\}
| | `caCertPath` _string_ | CACertPath is the file path to a CA certificate bundle for the OTLP endpoint.
When set, the OTLP exporters use this CA to verify the collector's TLS certificate
instead of relying solely on the system CA pool. | | Optional: \{\}
| +| `enableUserIDAttribute` _boolean_ | EnableUserIDAttribute controls whether the authenticated subject is emitted
as the OTEL "user.id" span attribute on the inbound MCP server span.
When false (the default), no user attribution lands on spans and behavior is
unchanged. When true, "user.id" is set from the authenticated identity's
Subject only when an identity is present on the request context, so
anonymous requests are unaffected.

Defaults to false because the subject can be personally- or
tenant-identifying. The attribute is high-cardinality and is intentionally
never added to any metric instrument. | false | Optional: \{\}
| @@ -2741,6 +2742,7 @@ _Appears in:_ | `metrics` _[api.v1beta1.OpenTelemetryMetricsConfig](#apiv1beta1opentelemetrymetricsconfig)_ | Metrics defines OpenTelemetry metrics-specific configuration | | Optional: \{\}
| | `tracing` _[api.v1beta1.OpenTelemetryTracingConfig](#apiv1beta1opentelemetrytracingconfig)_ | Tracing defines OpenTelemetry tracing configuration | | Optional: \{\}
| | `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy attribute names are emitted alongside
the new MCP OTEL semantic convention names. Defaults to true for backward compatibility.
This will change to false in a future release and eventually be removed. | true | Optional: \{\}
| +| `enableUserIDAttribute` _boolean_ | EnableUserIDAttribute controls whether the authenticated subject is emitted as the
OTEL "user.id" span attribute on the inbound MCP server span. Defaults to false because
the subject can be personally- or tenant-identifying. When enabled, the attribute is only
set when an authenticated identity is present on the request context, so anonymous requests
are unaffected. The attribute is high-cardinality and is never added to any metric instrument. | false | Optional: \{\}
| | `caBundleRef` _[api.v1beta1.CABundleSource](#apiv1beta1cabundlesource)_ | CABundleRef references a ConfigMap containing a CA certificate bundle for the OTLP endpoint.
When specified, the operator mounts the ConfigMap into the proxyrunner pod and configures
the OTLP exporters to trust the custom CA. This is useful when the OTLP collector uses
TLS with certificates signed by an internal or private CA. | | Optional: \{\}
| diff --git a/docs/server/docs.go b/docs/server/docs.go index 8a5e866faa..54163de572 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1832,6 +1832,10 @@ const docTemplate = `{ "description": "EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint.\nThe metrics are served on the main transport port at /metrics.\nThis is separate from OTLP metrics which are sent to the Endpoint.\n+kubebuilder:default=false\n+optional", "type": "boolean" }, + "enableUserIDAttribute": { + "description": "EnableUserIDAttribute controls whether the authenticated subject is emitted\nas the OTEL \"user.id\" span attribute on the inbound MCP server span.\nWhen false (the default), no user attribution lands on spans and behavior is\nunchanged. When true, \"user.id\" is set from the authenticated identity's\nSubject only when an identity is present on the request context, so\nanonymous requests are unaffected.\n\nDefaults to false because the subject can be personally- or\ntenant-identifying. The attribute is high-cardinality and is intentionally\nnever added to any metric instrument.\n+kubebuilder:default=false\n+optional", + "type": "boolean" + }, "endpoint": { "description": "Endpoint is the OTLP endpoint URL\n+optional", "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 8dc3869b85..b3b7f247e3 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1825,6 +1825,10 @@ "description": "EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint.\nThe metrics are served on the main transport port at /metrics.\nThis is separate from OTLP metrics which are sent to the Endpoint.\n+kubebuilder:default=false\n+optional", "type": "boolean" }, + "enableUserIDAttribute": { + "description": "EnableUserIDAttribute controls whether the authenticated subject is emitted\nas the OTEL \"user.id\" span attribute on the inbound MCP server span.\nWhen false (the default), no user attribution lands on spans and behavior is\nunchanged. When true, \"user.id\" is set from the authenticated identity's\nSubject only when an identity is present on the request context, so\nanonymous requests are unaffected.\n\nDefaults to false because the subject can be personally- or\ntenant-identifying. The attribute is high-cardinality and is intentionally\nnever added to any metric instrument.\n+kubebuilder:default=false\n+optional", + "type": "boolean" + }, "endpoint": { "description": "Endpoint is the OTLP endpoint URL\n+optional", "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 63d3dd713f..cde2d7172c 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1807,6 +1807,21 @@ components: +kubebuilder:default=false +optional type: boolean + enableUserIDAttribute: + description: |- + EnableUserIDAttribute controls whether the authenticated subject is emitted + as the OTEL "user.id" span attribute on the inbound MCP server span. + When false (the default), no user attribution lands on spans and behavior is + unchanged. When true, "user.id" is set from the authenticated identity's + Subject only when an identity is present on the request context, so + anonymous requests are unaffected. + + Defaults to false because the subject can be personally- or + tenant-identifying. The attribute is high-cardinality and is intentionally + never added to any metric instrument. + +kubebuilder:default=false + +optional + type: boolean endpoint: description: |- Endpoint is the OTLP endpoint URL diff --git a/pkg/config/config.go b/pkg/config/config.go index 7151912793..f51d4d548c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -400,6 +400,7 @@ type OpenTelemetryConfig struct { Insecure bool `yaml:"insecure,omitempty"` EnablePrometheusMetricsPath bool `yaml:"enable-prometheus-metrics-path,omitempty"` UseLegacyAttributes *bool `yaml:"use-legacy-attributes"` + EnableUserIDAttribute bool `yaml:"enable-user-id-attribute,omitempty"` } // getRuntimeConfig returns the runtime configuration for a given transport type diff --git a/pkg/runner/config_builder.go b/pkg/runner/config_builder.go index c3760147b0..2b4a64b618 100644 --- a/pkg/runner/config_builder.go +++ b/pkg/runner/config_builder.go @@ -543,6 +543,7 @@ func WithTelemetryConfigFromFlags( otelInsecure bool, otelEnvironmentVariables []string, otelUseLegacyAttributes bool, + otelEnableUserIDAttribute bool, ) RunConfigBuilderOption { config := telemetry.MaybeMakeConfig( otelEndpoint, @@ -555,6 +556,7 @@ func WithTelemetryConfigFromFlags( otelInsecure, otelEnvironmentVariables, otelUseLegacyAttributes, + otelEnableUserIDAttribute, ) return WithTelemetryConfig(config) } diff --git a/pkg/runner/config_test.go b/pkg/runner/config_test.go index ad66252c9a..f3a433ddd8 100644 --- a/pkg/runner/config_test.go +++ b/pkg/runner/config_test.go @@ -872,7 +872,7 @@ func TestRunConfigBuilder(t *testing.T) { WithLabels(nil), WithGroup(""), WithOIDCConfig(oidcIssuer, oidcAudience, oidcJwksURL, "", oidcClientID, "", "", "", "", false, false, nil), - WithTelemetryConfigFromFlags("", false, false, false, "", 0.1, nil, false, nil, false), + WithTelemetryConfigFromFlags("", false, false, false, "", 0.1, nil, false, nil, false, false), WithToolsFilter(nil), WithIgnoreConfig(&ignore.Config{ LoadGlobal: false, @@ -994,7 +994,7 @@ func TestRunConfigBuilder_OIDCScopes(t *testing.T) { false, tt.scopes, ), - WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false), + WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false, false), WithToolsFilter(nil), WithIgnoreConfig(&ignore.Config{ LoadGlobal: false, @@ -1216,7 +1216,7 @@ func TestRunConfigBuilder_MetadataOverrides(t *testing.T) { WithLabels(nil), WithGroup(""), WithOIDCConfig("", "", "", "", "", "", "", "", "", false, false, nil), - WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false), + WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false, false), WithToolsFilter(nil), WithIgnoreConfig(&ignore.Config{ LoadGlobal: false, @@ -1261,7 +1261,7 @@ func TestRunConfigBuilder_EnvironmentVariableTransportDependency(t *testing.T) { WithLabels(nil), WithGroup(""), WithOIDCConfig("", "", "", "", "", "", "", "", "", false, false, nil), - WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false), + WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false, false), WithToolsFilter(nil), WithIgnoreConfig(&ignore.Config{ LoadGlobal: false, @@ -1311,7 +1311,7 @@ func TestRunConfigBuilder_CmdArgsMetadataOverride(t *testing.T) { WithLabels(nil), WithGroup(""), WithOIDCConfig("", "", "", "", "", "", "", "", "", false, false, nil), - WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false), + WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false, false), WithToolsFilter(nil), WithIgnoreConfig(&ignore.Config{ LoadGlobal: false, @@ -1363,7 +1363,7 @@ func TestRunConfigBuilder_CmdArgsMetadataDefaults(t *testing.T) { WithLabels(nil), WithGroup(""), WithOIDCConfig("", "", "", "", "", "", "", "", "", false, false, nil), - WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false), + WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false, false), WithToolsFilter(nil), WithIgnoreConfig(&ignore.Config{ LoadGlobal: false, @@ -1415,7 +1415,7 @@ func TestRunConfigBuilder_VolumeProcessing(t *testing.T) { WithLabels(nil), WithGroup(""), WithOIDCConfig("", "", "", "", "", "", "", "", "", false, false, nil), - WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false), + WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false, false), WithToolsFilter(nil), WithIgnoreConfig(&ignore.Config{ LoadGlobal: false, @@ -1485,7 +1485,7 @@ func TestRunConfigBuilder_FilesystemMCPScenario(t *testing.T) { WithLabels(nil), WithGroup(""), WithOIDCConfig("", "", "", "", "", "", "", "", "", false, false, nil), - WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false), + WithTelemetryConfigFromFlags("", false, false, false, "", 0, nil, false, nil, false, false), WithToolsFilter(nil), WithIgnoreConfig(&ignore.Config{ LoadGlobal: false, diff --git a/pkg/runner/telemetry_config.go b/pkg/runner/telemetry_config.go index cc7728b94d..851735edd3 100644 --- a/pkg/runner/telemetry_config.go +++ b/pkg/runner/telemetry_config.go @@ -93,6 +93,7 @@ func BuildTelemetryConfigFromAppConfig( EnvironmentVariables: processedEnvVars, CustomAttributes: customAttrs, UseLegacyAttributes: useLegacyAttributes, + EnableUserIDAttribute: otel.EnableUserIDAttribute, } cfg.SetSamplingRateFromFloat(otel.SamplingRate) return cfg diff --git a/pkg/telemetry/config.go b/pkg/telemetry/config.go index 33eddc208b..32f07f54a1 100644 --- a/pkg/telemetry/config.go +++ b/pkg/telemetry/config.go @@ -102,6 +102,20 @@ type Config struct { // instead of relying solely on the system CA pool. // +optional CACertPath string `json:"caCertPath,omitempty" yaml:"caCertPath,omitempty"` + + // EnableUserIDAttribute controls whether the authenticated subject is emitted + // as the OTEL "user.id" span attribute on the inbound MCP server span. + // When false (the default), no user attribution lands on spans and behavior is + // unchanged. When true, "user.id" is set from the authenticated identity's + // Subject only when an identity is present on the request context, so + // anonymous requests are unaffected. + // + // Defaults to false because the subject can be personally- or + // tenant-identifying. The attribute is high-cardinality and is intentionally + // never added to any metric instrument. + // +kubebuilder:default=false + // +optional + EnableUserIDAttribute bool `json:"enableUserIDAttribute,omitempty" yaml:"enableUserIDAttribute,omitempty"` } // Ensure Config implements fmt.Stringer and fmt.GoStringer @@ -183,6 +197,7 @@ func MaybeMakeConfig( otelInsecure bool, otelEnvironmentVariables []string, otelUseLegacyAttributes bool, + otelEnableUserIDAttribute bool, ) *Config { if otelEndpoint == "" && !otelEnablePrometheusMetricsPath { return nil @@ -220,6 +235,7 @@ func MaybeMakeConfig( EnablePrometheusMetricsPath: otelEnablePrometheusMetricsPath, EnvironmentVariables: processedEnvVars, UseLegacyAttributes: otelUseLegacyAttributes, + EnableUserIDAttribute: otelEnableUserIDAttribute, } } diff --git a/pkg/telemetry/middleware.go b/pkg/telemetry/middleware.go index cc17e5f4db..d73fd4cddd 100644 --- a/pkg/telemetry/middleware.go +++ b/pkg/telemetry/middleware.go @@ -23,6 +23,7 @@ import ( "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" + "github.com/stacklok/toolhive/pkg/auth" mcpparser "github.com/stacklok/toolhive/pkg/mcp" "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -358,6 +359,32 @@ func (m *HTTPMiddleware) addMCPAttributes(ctx context.Context, span trace.Span, if parsedMCP.IsBatch { span.SetAttributes(attribute.Bool("mcp.is_batch", true)) } + + // Add authenticated-user attribution (opt-in, default-off) + m.addUserIDAttribute(ctx, span) +} + +// addUserIDAttribute emits the OTEL standard "user.id" span attribute, sourced +// from the authenticated subject on the request context, when the feature is +// explicitly enabled in the telemetry config. +// +// This is default-off because the subject can be personally- or +// tenant-identifying. When disabled (the default) nothing is emitted, leaving +// behavior unchanged. When enabled, the attribute is only set if an identity is +// present on the context with a non-empty Subject, so anonymous/unauthenticated +// requests are unaffected. "user.id" is intentionally not added to any metric +// instrument because it is high-cardinality. +func (m *HTTPMiddleware) addUserIDAttribute(ctx context.Context, span trace.Span) { + if !m.config.EnableUserIDAttribute { + return + } + + identity, ok := auth.IdentityFromContext(ctx) + if !ok || identity == nil || identity.Subject == "" { + return + } + + span.SetAttributes(attribute.String("user.id", identity.Subject)) } // addNetworkAttributes adds network, client, and session attributes to the span. diff --git a/pkg/telemetry/middleware_test.go b/pkg/telemetry/middleware_test.go index 32797cc25e..9790d2e763 100644 --- a/pkg/telemetry/middleware_test.go +++ b/pkg/telemetry/middleware_test.go @@ -26,6 +26,7 @@ import ( tracenoop "go.opentelemetry.io/otel/trace/noop" "go.uber.org/mock/gomock" + "github.com/stacklok/toolhive/pkg/auth" mcpparser "github.com/stacklok/toolhive/pkg/mcp" "github.com/stacklok/toolhive/pkg/transport/types" "github.com/stacklok/toolhive/pkg/transport/types/mocks" @@ -2399,3 +2400,136 @@ func TestRecordSSEConnection_DualEmission(t *testing.T) { }) } } + +// TestHTTPMiddleware_UserIDAttribute verifies the opt-in, default-off "user.id" +// span attribute behavior: +// - disabled (default): no user.id even when an identity is present, +// - enabled with an identity: user.id set from the authenticated Subject, +// - enabled without an identity (anonymous): no user.id. +func TestHTTPMiddleware_UserIDAttribute(t *testing.T) { + t.Parallel() + + const subject = "user-123" + + tests := []struct { + name string + enabled bool + withIdent bool + identity *auth.Identity + wantPresent bool + wantValue string + }{ + { + name: "disabled by default - no user.id even with identity", + enabled: false, + withIdent: true, + identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: subject}}, + wantPresent: false, + }, + { + name: "enabled with identity - user.id set from Subject", + enabled: true, + withIdent: true, + identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: subject}}, + wantPresent: true, + wantValue: subject, + }, + { + name: "enabled without identity (anonymous) - no user.id", + enabled: true, + withIdent: false, + wantPresent: false, + }, + { + name: "enabled with identity but empty Subject - no user.id", + enabled: true, + withIdent: true, + identity: &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: ""}}, + wantPresent: false, + }, + { + name: "enabled with nil identity stored - no user.id", + enabled: true, + withIdent: false, + wantPresent: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + middleware := &HTTPMiddleware{ + config: Config{EnableUserIDAttribute: tt.enabled}, + serverName: "github", + transport: "stdio", + } + + ctx := context.Background() + if tt.withIdent { + ctx = auth.WithIdentity(ctx, tt.identity) + } + + span := &mockSpan{attributes: make(map[string]interface{})} + middleware.addUserIDAttribute(ctx, span) + + if tt.wantPresent { + require.Contains(t, span.attributes, "user.id") + assert.Equal(t, tt.wantValue, span.attributes["user.id"]) + } else { + assert.NotContains(t, span.attributes, "user.id") + } + }) + } +} + +// TestHTTPMiddleware_UserIDAttribute_ViaAddMCPAttributes verifies the attribute +// is wired through the real addMCPAttributes path (identity present vs absent), +// and that it stays absent when the feature is off. +func TestHTTPMiddleware_UserIDAttribute_ViaAddMCPAttributes(t *testing.T) { + t.Parallel() + + newReqCtx := func(t *testing.T, ident *auth.Identity) (*http.Request, context.Context) { + t.Helper() + req := httptest.NewRequest("POST", "/messages", nil) + mcpRequest := &mcpparser.ParsedMCPRequest{ + Method: "tools/call", + ID: "req-1", + IsRequest: true, + } + ctx := context.WithValue(req.Context(), mcpparser.MCPRequestContextKey, mcpRequest) + if ident != nil { + ctx = auth.WithIdentity(ctx, ident) + } + return req, ctx + } + + t.Run("off - identity present but no user.id", func(t *testing.T) { + t.Parallel() + mw := &HTTPMiddleware{config: Config{EnableUserIDAttribute: false}, serverName: "github", transport: "stdio"} + req, ctx := newReqCtx(t, &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}}) + span := &mockSpan{attributes: make(map[string]interface{})} + mw.addMCPAttributes(ctx, span, req) + assert.NotContains(t, span.attributes, "user.id") + // Sanity: standard MCP attributes are still emitted. + assert.Contains(t, span.attributes, "mcp.method.name") + }) + + t.Run("on - identity present sets user.id", func(t *testing.T) { + t.Parallel() + mw := &HTTPMiddleware{config: Config{EnableUserIDAttribute: true}, serverName: "github", transport: "stdio"} + req, ctx := newReqCtx(t, &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}}) + span := &mockSpan{attributes: make(map[string]interface{})} + mw.addMCPAttributes(ctx, span, req) + assert.Equal(t, "alice", span.attributes["user.id"]) + }) + + t.Run("on - no identity (anonymous request) leaves user.id absent", func(t *testing.T) { + t.Parallel() + mw := &HTTPMiddleware{config: Config{EnableUserIDAttribute: true}, serverName: "github", transport: "stdio"} + req, ctx := newReqCtx(t, nil) + span := &mockSpan{attributes: make(map[string]interface{})} + mw.addMCPAttributes(ctx, span, req) + assert.NotContains(t, span.attributes, "user.id") + }) +} From 8003a76ac9786f3f424d39bb5847776346df0429 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 20:57:21 +0000 Subject: [PATCH 2/3] fix(telemetry): reduce getTelemetryFromFlags complexity and regenerate CLI docs Extract the repeated 'flag value or config fallback' bool resolution into a boolFlagOrConfig helper so getTelemetryFromFlags drops back below the gocyclo threshold (the new --otel-enable-user-id-attribute fallback had pushed it to 16). Regenerate docs/cli/thv_run.md so the --otel-enable-user-id-attribute flag is documented, matching the swagger/docgen verification output. --- cmd/thv/app/run_flags.go | 29 ++++++++++++++++------------- docs/cli/thv_run.md | 1 + 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index 94185a4735..2fbe35ba55 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -1078,6 +1078,15 @@ type finalTelemetry struct { OtelMetricsEnabled bool } +// boolFlagOrConfig returns the CLI flag value when the user explicitly set it, +// otherwise it falls back to the supplied config value. +func boolFlagOrConfig(cmd *cobra.Command, name string, flagValue, configValue bool) bool { + if cmd.Flags().Changed(name) { + return flagValue + } + return configValue +} + // getTelemetryFromFlags extracts telemetry configuration from command flags func getTelemetryFromFlags(cmd *cobra.Command, config *cfg.Config, otelEndpoint string, otelSamplingRate float64, otelEnvironmentVariables []string, otelInsecure bool, otelEnablePrometheusMetricsPath bool, @@ -1099,15 +1108,11 @@ func getTelemetryFromFlags(cmd *cobra.Command, config *cfg.Config, otelEndpoint finalOtelEnvironmentVariables = config.OTEL.EnvVars } - finalOtelInsecure := otelInsecure - if !cmd.Flags().Changed("otel-insecure") { - finalOtelInsecure = config.OTEL.Insecure - } - - finalOtelEnablePrometheusMetricsPath := otelEnablePrometheusMetricsPath - if !cmd.Flags().Changed("otel-enable-prometheus-metrics-path") { - finalOtelEnablePrometheusMetricsPath = config.OTEL.EnablePrometheusMetricsPath - } + // Simple bool flags fall back to the config value whenever the flag was not + // explicitly set on the command line. + finalOtelInsecure := boolFlagOrConfig(cmd, "otel-insecure", otelInsecure, config.OTEL.Insecure) + finalOtelEnablePrometheusMetricsPath := boolFlagOrConfig( + cmd, "otel-enable-prometheus-metrics-path", otelEnablePrometheusMetricsPath, config.OTEL.EnablePrometheusMetricsPath) finalOtelTracingEnabled := otelTracingEnabled if !cmd.Flags().Changed("otel-tracing-enabled") && config.OTEL.TracingEnabled != nil { @@ -1130,10 +1135,8 @@ func getTelemetryFromFlags(cmd *cobra.Command, config *cfg.Config, otelEndpoint // EnableUserIDAttribute defaults to false (opt-in). The config value is used // as a fallback only when the CLI flag was not explicitly set. - finalOtelEnableUserIDAttribute := otelEnableUserIDAttribute - if !cmd.Flags().Changed("otel-enable-user-id-attribute") { - finalOtelEnableUserIDAttribute = config.OTEL.EnableUserIDAttribute - } + finalOtelEnableUserIDAttribute := boolFlagOrConfig( + cmd, "otel-enable-user-id-attribute", otelEnableUserIDAttribute, config.OTEL.EnableUserIDAttribute) return finalTelemetry{ OtelEndpoint: finalOtelEndpoint, diff --git a/docs/cli/thv_run.md b/docs/cli/thv_run.md index 4c0d110062..291025efd6 100644 --- a/docs/cli/thv_run.md +++ b/docs/cli/thv_run.md @@ -143,6 +143,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] --oidc-scopes strings OAuth scopes to advertise in the well-known endpoint (RFC 9728, defaults to 'openid' if not specified) --otel-custom-attributes string Custom resource attributes for OpenTelemetry in key=value format (e.g., server_type=prod,region=us-east-1,team=platform) --otel-enable-prometheus-metrics-path Enable Prometheus-style /metrics endpoint on the main transport port (default false) + --otel-enable-user-id-attribute Emit the authenticated subject as the user.id span attribute on the MCP server span (default false; may expose personally- or tenant-identifying data) --otel-endpoint string OpenTelemetry OTLP endpoint URL (e.g., https://api.honeycomb.io) --otel-env-vars stringArray Environment variable names to include in OpenTelemetry spans (comma-separated: ENV1,ENV2) --otel-headers stringArray OpenTelemetry OTLP headers in key=value format (e.g., x-honeycomb-team=your-api-key) From 98db84bf4422c45c857c7465aa864a761fc44a36 Mon Sep 17 00:00:00 2001 From: toolhive Date: Thu, 25 Jun 2026 21:08:19 +0000 Subject: [PATCH 3/3] chore(operator): regenerate CRD docs for enableUserIDAttribute --- docs/operator/crd-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 04f62c0a44..5654677d99 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -886,7 +886,7 @@ _Appears in:_ | `customAttributes` _object (keys:string, values:string)_ | CustomAttributes contains custom resource attributes to be added to all telemetry signals.
These are parsed from CLI flags (--otel-custom-attributes) or environment variables
(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs. | | Optional: \{\}
| | `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy (pre-MCP OTEL semconv) attribute names
are emitted alongside the new standard attribute names. When true, spans include both
old and new attribute names for backward compatibility with existing dashboards.
Currently defaults to true; this will change to false in a future release. | true | Optional: \{\}
| | `caCertPath` _string_ | CACertPath is the file path to a CA certificate bundle for the OTLP endpoint.
When set, the OTLP exporters use this CA to verify the collector's TLS certificate
instead of relying solely on the system CA pool. | | Optional: \{\}
| -| `enableUserIDAttribute` _boolean_ | EnableUserIDAttribute controls whether the authenticated subject is emitted
as the OTEL "user.id" span attribute on the inbound MCP server span.
When false (the default), no user attribution lands on spans and behavior is
unchanged. When true, "user.id" is set from the authenticated identity's
Subject only when an identity is present on the request context, so
anonymous requests are unaffected.

Defaults to false because the subject can be personally- or
tenant-identifying. The attribute is high-cardinality and is intentionally
never added to any metric instrument. | false | Optional: \{\}
| +| `enableUserIDAttribute` _boolean_ | EnableUserIDAttribute controls whether the authenticated subject is emitted
as the OTEL "user.id" span attribute on the inbound MCP server span.
When false (the default), no user attribution lands on spans and behavior is
unchanged. When true, "user.id" is set from the authenticated identity's
Subject only when an identity is present on the request context, so
anonymous requests are unaffected.
Defaults to false because the subject can be personally- or
tenant-identifying. The attribute is high-cardinality and is intentionally
never added to any metric instrument. | false | Optional: \{\}
|