From ct
Provides deep operational guidance for OpenTelemetry SDK and Collector tuning, including propagator selection, sampling design, semantic-convention migration, and cardinality control.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ct:opentelemetryThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Concise operational pointers for deep OpenTelemetry SDK and Collector troubleshooting and tuning.
Concise operational pointers for deep OpenTelemetry SDK and Collector troubleshooting and tuning.
Assumes you already know what traces/metrics/logs are, what a span is, and that propagation carries context across services. This skill covers the operational layer — the parts models tend to gloss over: propagator selection in mixed fleets, sampling design, semantic-convention stability, collector pipeline ordering, OTLP transport quirks, instrumentation-library upgrade pain, and cardinality cost control.
Load when the question is about:
OTEL_PROPAGATORS)OTEL_SEMCONV_STABILITY_OPT_IN)memory_limiter first, batch last)service.name = unknown_service diagnosisEnd(), mutations after end, orphan parents)trace_id/span_id injectionDo NOT load for: explaining what tracing is, picking a backend, first-time install, basic span-creation tutorials, or "show me a Grafana dashboard."
TracerProvider, MeterProvider, LoggerProvider. They share Resource and propagation context but otherwise evolve on independent stability tracks. Trace SDK is stable; Metric SDK stable since 1.x; Log SDK stable for the bridge surface, individual log appenders still vary.service.name is REQUIRED by spec; missing it → SDKs fall back to literal unknown_service (or unknown_service:python, etc.). Spans bucket under that string in every backend.service.name by precedence: OTEL_SERVICE_NAME env (highest), OTEL_RESOURCE_ATTRIBUTES=service.name=foo, programmatic Resource.builder().put("service.name", ...), then SDK default.service.version, service.namespace, service.instance.id, deployment.environment.name (renamed from deployment.environment in newer semconv).host.id, host.name, os.type, process.pid, process.runtime.*, container.id (parsed from /proc/self/cgroup). Kubernetes attrs (k8s.pod.name, k8s.namespace.name, k8s.node.name) come from either: (a) Downward API → OTEL_RESOURCE_ATTRIBUTES, or (b) the k8sattributes collector processor enriching at the gateway. Prefer the collector path — survives missing pod-spec env vars.tracecontext,baggage (W3C). traceparent: 00-{trace_id_32hex}-{span_id_16hex}-{flags_2hex} plus tracestate for vendor-specific add-ons. Only the W3C TraceContext Level 2 random-flag is required for consistent probability sampling.OTEL_PROPAGATORS is comma-separated and order matters for injection (the first propagator that matches on extract wins; all are written on inject). Known values: tracecontext, baggage, b3 (single header), b3multi (X-B3-TraceId/X-B3-SpanId/X-B3-Sampled), jaeger (uber-trace-id, deprecated), xray (third-party), ottrace (deprecated), none.OTEL_PROPAGATORS=tracecontext,baggage,b3multi. New services emit W3C; old services keep working. Drop b3multi only after the last B3 emitter is gone.baggage header): cross-cutting key-value, propagated alongside trace context. W3C cap: 8192 bytes total, 64 list-members; OpenTelemetry Python enforces 4096 per value, 180 entries. Excess silently dropped on inject — no error.baggage SDK span processor (Java/Python) or a transform collector processor.traceparent flags byte (01 = sampled). If a parent service samples-out (flags=00), all downstream services inherit "not sampled" under ParentBased — child AlwaysOn doesn't override, by design.Head-based — decision at span start, in the SDK:
AlwaysOn / AlwaysOff — trivial.TraceIdRatioBased(0.1) — deterministic hash of trace_id, 10% of traces. Same trace_id = same decision across services (consistent).ParentBased(root=TraceIdRatioBased(0.1)) — default in most SDKs. If parent context exists, follow parent's sampled flag. Otherwise apply root sampler. Prevents partial traces.OTEL_TRACES_SAMPLER defaults to parentbased_always_on; for ratio sampling set OTEL_TRACES_SAMPLER=parentbased_traceidratio and OTEL_TRACES_SAMPLER_ARG=0.1.tracestate (ot=th:...) so downstream collectors / backends know the effective rate without re-deriving it. Required for accurate rate analytics under sampling. Adoption is rolling out per-SDK in 2025/2026.Tail-based — decision after the trace completes, in the collector:
tail_sampling processor (in opentelemetry-collector-contrib). Buffers all spans of a trace for decision_wait (default 30s), then evaluates policies:
latency (p95 > N ms), status_code (ERROR), string_attribute (e.g. tenant), numeric_attribute, rate_limiting (spans/sec), probabilistic, composite (AND/OR of policies).Coordinator+sampler topology (canonical): two-tier collector deployment.
loadbalancing exporter with routing_key: traceID, resolver: dns (Kubernetes headless service) or static. Hashes trace_id → routes to a fixed Tier-2 replica.tail_sampling processor. Stateful — sticky per trace_id.loadbalancing exporter routing keys: traceID (default for traces), service (default for metrics), streamID for logs, resource for resource-grouped routing.otelcol_loadbalancer_num_backends — if it doesn't match expected replicas, DNS hasn't converged or readiness is stuck.The contract between instrumentation and backends. Stability matters or your dashboards break on SDK upgrade.
development (was experimental) → alpha → beta → release_candidate → stable. Only stable is a breaking-change-protected contract. Default is development if unset.semantic-conventions/model/<area> stability: field before depending.http.method → http.request.method, http.status_code → http.response.status_code, http.url → url.full, http.host → server.address + server.port, http.scheme → url.scheme, http.target → url.path + url.query, net.peer.name → server.address. Span name template changed to {method} {http.route} (e.g. GET /users/:id).OTEL_SEMCONV_STABILITY_OPT_IN controls instrumentation library output: http (new only), http/dup (both old and new — for migration windows), unset (old only, deprecated). Per-domain comma-list possible (http,db).db.system.name (was db.system), db.namespace, db.query.text (was db.statement), messaging.system, messaging.destination.name, messaging.operation.type, rpc.system, rpc.service, rpc.method, network.peer.address, network.peer.port, network.protocol.name.GET /users/:id (route template) — NOT GET /users/12345. Raw URLs in span name → metrics-from-spans (RED) explode.http://collector:4317 (no path, the gRPC service name is in the proto)./v1/traces, /v1/metrics, /v1/logs. SDKs append automatically when given a base URL only for HTTP, and only on OTEL_EXPORTER_OTLP_ENDPOINT, not on signal-specific overrides like OTEL_EXPORTER_OTLP_TRACES_ENDPOINT (which is treated as the full URL including path).OTEL_EXPORTER_OTLP_PROTOCOL: grpc (default), http/protobuf, http/json. Mismatch with collector port = silent connection refused or 404. Pointing gRPC at 4318 → "transport closed" / UNAVAILABLE. Pointing HTTP at 4317 → handshake failure or "Method Not Allowed" / 405.Content-Type if both protocols listen on the same port — the collector OTLP receiver supports this when both protocols.grpc and protocols.http are configured. Standard receiver YAML still binds 4317 + 4318 separately; multiplexing requires explicit shared bind.OTEL_EXPORTER_OTLP_HEADERS=key=val,key2=val2) — comma-separated, URL-encoded values. SaaS backends use this for API keys.OTEL_EXPORTER_OTLP_COMPRESSION=gzip. ~5-10× payload reduction on traces; cheap CPU cost; turn on by default.OTEL_BSP_SCHEDULE_DELAY=5000 (ms between exports), OTEL_BSP_MAX_QUEUE_SIZE=2048, OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512. High span rate + small queue = drops with BatchSpanProcessor warnings; raise queue or shorten delay.Pipelines are per-signal. Order of processors matters semantically:
receivers:
otlp:
protocols: { grpc: { endpoint: 0.0.0.0:4317 }, http: { endpoint: 0.0.0.0:4318 } }
processors:
memory_limiter:
check_interval: 1s
limit_mib: 4000
spike_limit_mib: 800
k8sattributes: {} # gateway only
resource: { attributes: [{ key: deployment.environment.name, value: prod, action: upsert }] }
tail_sampling: { decision_wait: 30s, policies: [...] }
batch: { timeout: 10s, send_batch_size: 8192 }
exporters:
otlp/tempo: { endpoint: tempo:4317, tls: { insecure: true } }
service:
pipelines:
traces: { receivers: [otlp], processors: [memory_limiter, k8sattributes, resource, tail_sampling, batch], exporters: [otlp/tempo] }
memory_limiter MUST be first. Applies backpressure at the receiver before any allocation downstream. Without it, a burst → OOM kill → data loss > a controlled drop.
limit_mib: hard cap. spike_limit_mib: distance below limit_mib at which soft refusal starts (default 20% of limit_mib).GOMEMLIMIT=80%-of-container-limit env var — Go runtime cooperates with the soft limit.batch MUST be last in the processor chain. Batching before sampling/dropping wastes work batching data that gets discarded.batch, after memory_limiter. Tail sampling's buffering is its own memory pressure — memory_limiter first protects against it.k8sattributes processor enriches with pod metadata via Kubernetes API. Place after memory_limiter, before any processor that depends on those attrs. Needs RBAC — pods get/list/watch, optionally replicasets and deployments.service.pipelines.traces, .metrics, .logs. A processor configured but not referenced in any pipeline silently doesn't run.Three patterns, often combined:
$NODE_IP:4317. Wins: low latency, no network egress per app, scales 1:1 with nodes. Use for receiving + batching + minor enrichment, then forward upstream.Start → set attributes/events/status → End. After End(), the span is immutable; mutations are silently dropped. Set RecordException and SetStatus(Error) before End.End() = SDK memory leak. Span object stays in the active-set, never reaches BatchSpanProcessor. Use language idioms: Go defer span.End(), Python with tracer.start_as_current_span(...), Java try-with-resources on Scope, Node.js tracer.startActiveSpan(name, span => {...; span.end()}).Unset (default), Ok, Error (with description). Unset is correct for non-errors — instrumentation should NOT pre-set Ok. Backends interpret Error distinctively.BatchSpanProcessor flushed). The child still emits but its parent reference points to a span the backend has already stored — usually fine for trace assembly, but if parent and child go to different collectors during routing, the child can lose the parent. Tail-sampling decision_wait must be longer than the longest realistic span gap.| Language | Mechanism | Key invocation |
|---|---|---|
| Java | bytecode via javaagent JAR (JVMTI) | java -javaagent:opentelemetry-javaagent.jar -jar app.jar |
| Python | monkey-patch via wrapper | opentelemetry-bootstrap -a install then opentelemetry-instrument python app.py |
| Node.js | --require hook | NODE_OPTIONS="--require @opentelemetry/auto-instrumentations-node/register" |
| .NET | profiler attach | OpenTelemetry.AutoInstrumentation MSI / shell installer |
| Go | eBPF (no bytecode) | opentelemetry-go-instrumentation agent as sidecar (operator-injected); or Auto SDK for manual+auto bridge |
| Ruby | monkey-patch | OpenTelemetry::SDK.configure + c.use_all |
net/http, database/sql, gRPC, kafka-go. Runs as sidecar with CAP_SYS_ADMIN / CAP_BPF. For most teams: stick with manual instrumentation via otelhttp, otelgrpc, otelpgx.opentelemetry-operator) auto-injects per-language agents into pods via Instrumentation CRD + pod annotations (instrumentation.opentelemetry.io/inject-java=true). Sets JAVA_TOOL_OPTIONS / PYTHONPATH / NODE_OPTIONS — collides with user-set values in the same env vars (known issue; precedence is manifest > injected).http, Servlet, WSGI) auto-instruments → two spans per request, parent-child or sibling depending on hook depth. Disable one — usually the lower-level library — via per-instrumentation env (OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=urllib3) or disable in code at SDK init.opentelemetry-instrumentation-<lib> declares supported library version range (e.g., opentelemetry-instrumentation-flask >=1.0,<4.0). Mismatch silently degrades (no spans) or breaks startup with import errors. Pin in lockfile; check the contrib repo's per-instrumentation README for current bounds.Counter (monotonic up), UpDownCounter (signed), Histogram (distribution), Gauge (synchronous Gauge added 1.30+; previously only Observable).ObservableCounter, ObservableUpDownCounter, ObservableGauge. SDK calls back at collection time. Use for sampling external state (queue depth, DB connection count).le labels).view = View(
instrument_type=Counter,
instrument_name="http.server.request.count",
attribute_keys=["http.method", "http.response.status_code"], # drop everything else
)
attribute_keys whitelist drops all other attributes at the SDK boundary — backend sees only listed keys. Single biggest cardinality lever.user_id, trace_id, request_id, session_id, raw URL paths, K8s pod names with random suffix → series count explodes. Bound aggregate metrics with cardinality-safe attrs only; use exemplars (next section) to reach the high-cardinality detail.OTEL_METRICS_EXPORTER=otlp|prometheus|console|none, OTEL_METRIC_EXPORT_INTERVAL=60000 (ms), OTEL_METRIC_EXPORT_TIMEOUT=30000.(value, trace_id, span_id, timestamp, attributes) tuple attached to a histogram bucket / counter point. Lets users jump from a metric to one representative trace.OTEL_METRICS_EXEMPLAR_FILTER: trace_based (default — only emit when in active sampled span context), always_on, always_off.--enable-feature=exemplar-storage flag. Fixed-size in-memory ring buffer per series.OpenTelemetry's logs story is a bridge from existing loggers to the OTel data model — not a new logging API. Use the existing logger.
OpenTelemetryAppender for log4j2 / logback. The javaagent auto-bridges. MDC is auto-populated with trace_id, span_id, trace_flags for the active span — reference in pattern as %X{trace_id}. Log file then carries the trace pointer.LoggingHandler from opentelemetry-sdk._logs; or OTEL_PYTHON_LOG_CORRELATION=true injects via the LoggingInstrumentor to add otelTraceID/otelSpanID to the standard logging record's extra.go.opentelemetry.io/contrib/bridges/otelslog for log/slog; otelzap for zap. Bridge wraps a logger and injects context.@opentelemetry/instrumentation-<logger>.OTEL_LOGS_EXPORTER: otlp (default), console, none. Logs SDK is stable, but coverage of language-native logger bridges still varies.LoggingInstrumentor AND the LoggingHandler simultaneously double-emits — one writes to stdout, one writes via OTLP. Pick the path your collector pipeline expects.service.name unset → spans bucketed as unknown_service. Set OTEL_SERVICE_NAME in every container.OTEL_EXPORTER_OTLP_PROTOCOL=grpc against port 4318 → connection refused. HTTP against 4317 → 405.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is treated as the full URL including signal path; setting it to https://collector:4318 (no /v1/traces) breaks HTTP exports while leaving OTEL_EXPORTER_OTLP_ENDPOINT working — base vs override paths differ.loadbalancing exporter coordinator → split decisions, missing spans. Trace assembly looks fine in some traces, broken in others.tail_sampling.decision_wait shorter than your slowest span → decisions made on partial trace, late spans dropped.memory_limiter placed after batch → OOM kill before backpressure activates; data loss instead of controlled drop.OTEL_SEMCONV_STABILITY_OPT_IN unset on a deprecated instrumentation → still emits http.method etc.; new dashboards keyed on http.request.method show empty.OTEL_PROPAGATORS=tracecontext only, talking to a Zipkin/B3 service → context drops at the boundary, child spans get new trace_id (broken trace).user_id) → series count blows up at backend, cost/perf cliff. Use Views to drop, exemplars to reach.loadbalancing routing key isn't traceID.OTEL_BSP_MAX_QUEUE_SIZE too small under burst → BatchSpanProcessor queue full warnings + dropped spans. Raise queue OR shorten OTEL_BSP_SCHEDULE_DELAY.defer span.End() (or equivalent) on early-return paths → SDK memory grows, eventually OOM.Specification (opentelemetry.io/docs/specs/otel):
Semantic conventions (github.com/open-telemetry/semantic-conventions):
Collector (opentelemetry.io/docs/collector and github.com/open-telemetry/opentelemetry-collector-contrib):
Sampling and instrumentation guides:
Operator and Kubernetes:
Reliable authors: Ted Young, Steve Flanders (Splunk/OTel governance); Tyler Yahn, Jacob Aronoff (collector core); Trask Stalnaker (Java instrumentation lead); Daniel Dyla (JS); Lightstep / ServiceNow / Honeycomb / Grafana Labs OTel blogs.
Before recommending a non-trivial OTel change (sampler, propagator list, processor order, OTLP protocol switch, semconv migration):
opentelemetry.io/docs/specs/otel, the contrib README for the processor/exporter, or github.com/open-telemetry/semantic-conventions at a pinned version.decision_wait overflow, unknown_service count, sampler effective rate — never blanket-tune.http/dup (or per-domain dup) for one release window — never flip atomically while dashboards are live.Tuning without measurement is worse than defaults. Sampling rates, batch sizes, memory limits, and propagator order are environment-specific — a 1% sample that's correct for a 10k-RPS service is invisible at 10 RPS, and a propagator list that works in a homogeneous OTel fleet drops trace context the day a B3-emitting service joins.
npx claudepluginhub pvillega/claude-templates --plugin ctGuides OpenTelemetry SDK setup, custom instrumentation, and sending traces/metrics to Honeycomb. Useful when instrumenting apps, adding tracing, or configuring OTLP.
Guides OpenTelemetry collector configuration, pipeline design, production instrumentation, sampling, cardinality management, security, and OTTL transformations.
OpenTelemetry tracing discipline: correct spans, propagation, and semantic conventions produce useful traces. Invoke whenever task involves any interaction with distributed tracing — span creation, context propagation, instrumentation, sampling configuration, or OpenTelemetry SDK setup.