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 |
|---|---|---|
| Drive Traffic | CRITICAL | validate, traffic, run-id, e2e |
Telemetry only exists if something ran. You generate the traffic — asking the user to click around a dev server is the last rung of the ladder, not the plan. Driving it yourself makes the validation loop deterministic and repeatable.
Before launching the app, stamp every signal it emits with a unique run tag:
RUN_ID=$(uuidgen)
export OTEL_RESOURCE_ATTRIBUTES="validation.run_id=$RUN_ID"
export OTEL_SERVICE_NAME=my-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_BSP_SCHEDULE_DELAY=500 # flush every 500ms so short runs don't lose spansOTEL_RESOURCE_ATTRIBUTES is honoured by every OTel SDK and lands on the resource of
every span, log, and metric from that process. It buys you an exact query:
npx @kopai/cli traces search --resource-attr "validation.run_id=$RUN_ID" --jsonThat returns exactly this run — no time-window arithmetic, no stale spans from the
previous attempt, no cross-talk from other services on the same backend. Every assertion
in validate-traces filters on it.
Keep $RUN_ID in the shell for the whole loop. Mint a new one each time you re-drive
after a fix, so a stale pass can never be mistaken for a fresh one.
Work down. Each rung is a fallback for the one above.
If the repo has integration or end-to-end tests, run them with the OTel env vars set. Real code paths, real data shapes, zero new code:
npm test # or: go test ./... | pytest | bundle exec rspec | dotnet testUnit tests with everything mocked are usually worthless here — they never reach the I/O that auto-instrumentation hooks. Prefer whatever target boots the real app.
Enumerate the routes from the framework rather than guessing, then hit each one.
| Stack | Where the routes live |
|---|---|
| Express / Fastify | app.get/post(...) registrations; app._router.stack at runtime |
| Next.js | file tree under app/**/route.ts, app/**/page.tsx, pages/api/** |
| Go net/http, Gin, Echo, Chi | mux.Handle, r.GET(...), e.POST(...) registrations |
| Flask / FastAPI | @app.route / @app.get decorators; FastAPI also serves /openapi.json |
| Django | urlpatterns in urls.py |
| Rails | bin/rails routes |
| Spring | @GetMapping / @RequestMapping annotations |
Then drive them:
BASE=http://localhost:3000
for path in / /api/users /api/users/1 /api/orders; do
curl -s -o /dev/null -w "%{http_code} $path\n" "$BASE$path"
doneHit each route at least twice — a single sample can't show a latency distribution, and some caches only reveal themselves on the second call.
For anything that isn't an HTTP server — CLI tools, queue consumers, workers, cron jobs, batch pipelines — write a throwaway script that invokes the entry point once per code path, and delete it once the loop is green.
# queue consumer: publish the messages it consumes
node scripts/_drive.mjs # publishes one valid + one poison message
# CLI: invoke each subcommand
./mytool sync --dry-run
./mytool sync
./mytool export --format jsonPut the driver in the scratchpad or a _drive prefix so it is obviously disposable, and
confirm it is gone before reporting the work complete.
Only when the app genuinely cannot boot headlessly — it needs production credentials, an SSO login, a real payment gateway, or hardware. Say precisely what to exercise and what you'll check:
Start the app with these env vars and hit the checkout flow once, including one card decline. I'll assert on
validation.run_id=<id>when you tell me it's done.
Traffic that only returns 200 cannot prove your error instrumentation works. The "errors carry ERROR status and a slug" assertion passes vacuously when no error ever happened — which looks identical to success and is the most common way this loop lies to you.
Force at least one failure per error class the code handles:
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/api/users/99999999" # not found
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$BASE/api/orders" \
-H 'Content-Type: application/json' -d '{"malformed":' # bad payload
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/api/admin" # unauthorised
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/no-such-route" # 404If the service depends on something you can stop — a database, a cache, an upstream — stop it and drive one more request. That is the only way to see the dependency-failure path instrumented.
Short-lived processes exit before the batch span processor ships its buffer, which presents exactly like "no data received".
OTEL_BSP_SCHEDULE_DELAY=500 (and OTEL_METRIC_EXPORT_INTERVAL=1000 for metrics)kill $PID, not kill -9 $PIDsleep 2 after the process exitsvalidate-shutdown.mdtraces search --resource-attr "validation.run_id=$RUN_ID" returns a non-empty resultThen run validate-traces.md.
references