Instrument applications with the OpenTelemetry SDK and prove the telemetry is good by validating it against a local Kopai backend. Use when setting up observability, adding tracing/logging/metrics, deciding what to instrument or which attributes to add, retrofitting OTel into an existing codebase, threading context through call chains, configuring sampling, or when traces/logs/metrics aren't appearing after setup. Also use when users say things like "my traces aren't showing up", "I don't see any data", or "how do I add observability to my app". Do NOT use to investigate existing telemetry for a root cause (use root-cause-analysis), to build dashboards (use create-dashboard), or to instrument LLM and agent calls (use otel-genai-instrumentation).
—
—
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
| title | impact | tags |
|---|---|---|
| Missing Attributes | HIGH | troubleshoot, attributes |
Spans arrive, but they're too narrow to answer anything. First, see what you actually have:
npx @kopai/cli traces search --resource-attr "validation.run_id=$RUN_ID" --json \
| jq -r '.[].SpanAttributes // {} | keys[]' | sort -uDiff that against references/attributes.md.
Expected — no SDK can guess your user model or business domain. This isn't a bug, it's
the work: instrument-attributes.md, starting with who was affected, what changed, and
where the time went.
Set on the wrong span. SetAttributes applies to the span you're holding. Inside a
handler you usually want the current span from context, not a new one:
span := trace.SpanFromContext(ctx) // the active span
span.SetAttributes(attribute.String("user.id", userID))span = trace.get_current_span()
span.set_attribute("user.id", user_id)const span = trace.getActiveSpan();
span?.setAttribute("user.id", userId);Set after End(). Attributes added to an ended span are silently discarded. Set them
before the span closes — in Go, before the defer span.End() fires.
No active span. getActiveSpan() returning a non-recording span means context didn't
reach this code — context-propagation.md. In Node.js the ?. above hides this failure
entirely; check the span is recording if you suspect it.
Wrong type. Attribute values must be primitives or arrays of primitives. Passing a struct, dict, or object is dropped by some SDKs and stringified by others. Serialise deliberately, or pick out the fields you need as separate attributes.
Resource attributes are set once at SDK init, not per span. Set them via the environment so they can't drift:
export OTEL_RESOURCE_ATTRIBUTES="service.version=$(git rev-parse --short HEAD),deployment.environment=local"If they're set programmatically, confirm the resource is merged with the default rather
than replacing it — replacing it drops service.name and the SDK-detected fields.
http.route is a concrete URL/api/users/8823 instead of /api/users/{id} means the route pattern wasn't captured —
usually middleware registered outside the router, so it never sees the match. Every ID
becomes its own group and aggregation stops working. context-propagation.md.
High-cardinality values where you wanted categories — a raw message instead of a category,
a timestamp instead of a bucket. Add a low-cardinality companion rather than replacing the
detail: keep exception.message for reading, add exception.slug for grouping
(instrument-errors.md).
npx @kopai/cli traces search --resource-attr "validation.run_id=$RUN_ID" \
--span-attr "user.id=<a value you drove>" --json | jq 'length'Non-zero means the attribute is queryable, which is the only thing that counts.
https://opentelemetry.io/docs/concepts/semantic-conventions/
references