Add an OTel Span¶
Canonical example: AuthService.login at apps/api/src/auth/auth.service.ts:150-188.
When to add a manual span¶
Most code is already instrumented automatically — HTTP requests, Prisma queries, Redis
commands, and Pino log lines all get spans via the SDK configured in
libs/shared/src/basic/pre/pre-otel.main.ts:33-66.
Add a manual span only when: - The method is auth-critical (login, token verify) and you need its own span for RED metrics. - The method is slow/complex and you want to isolate its latency from the parent HTTP span. - You need custom attributes (user ID, bet amount, game type) that auto-instrumentation won't capture.
Do NOT wrap every service method — the auto-instrumented HTTP and Prisma spans already cover the majority of the call tree.
1. Import the OTel API¶
This is a zero-dependency import — @opentelemetry/api is the thin API contract.
The SDK registered in pre-otel.main.ts:68 provides the actual tracer at runtime.
2. Wrap with startActiveSpan¶
Follow the pattern from auth.service.ts:150-188:
async yourMethod(input: YourInput): Promise<YourOutput> {
return await trace
.getTracer('ebit-api')
.startActiveSpan('YourService.yourMethod', async (span) => {
try {
const result = await this.doWork(input);
span.end();
return result;
} catch (e) {
span.recordException(e as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
throw e;
}
});
}
Key rules from the canonical example:
- Tracer name: Use
'ebit-api'(matchesservice.nameinpre-otel.main.ts:35). - Span name:
ClassName.methodName— appears in Jaeger as a nested span under the HTTP root. - Always call
span.end()in both success and error paths (auth.service.ts:173,179,185). startActiveSpan(notstartSpan) ensures child spans (Prisma queries, Redis calls) are automatically parented under this span via async context propagation.
3. Add custom attributes (optional)¶
span.setAttribute('user.id', userId);
span.setAttribute('bet.currency', currencyId);
span.setAttribute('bet.amount_usd', parseFloat(usdAmount));
Attributes appear as key-value tags on the span in Jaeger. Use them for filtering and grouping — but don't add PII (emails, IPs).
4. Add span events (optional)¶
span.addEvent('mfa_check_passed', { method: 'totp' });
span.addEvent('session_created', { sessionId });
Events are timestamped log entries attached to the span — useful for tracing the internal steps of a long method without creating separate child spans.
5. Fire-and-forget child spans¶
For synchronous or non-critical work where you don't need async context:
const childSpan = trace.getTracer('ebit-api').startSpan('bcrypt.compare');
const isValid = await bcrypt.compare(password, hash);
childSpan.end();
startSpan (without "Active") creates the span without setting it as the
current context — downstream calls won't auto-parent under it.
What the SDK auto-instruments¶
These spans are created without any code changes (pre-otel.main.ts:46-65):
| Instrumentation | Span name pattern | Attributes |
|---|---|---|
| HTTP (auto) | GET /endpoint, POST /endpoint |
http.method, http.route, http.status_code |
| Prisma | prisma:client:operation |
prisma.model, prisma.method |
| ioredis (auto) | redis-EVALSHA, redis-GET |
db.system, db.statement |
| Pino (auto) | Log correlation only | Injects trace_id, span_id into log lines |
The spanmetrics connector in the OTel collector (observability/otel-collector.yml:74-83)
derives duration_milliseconds_bucket and calls_total Prometheus metrics from ALL spans
(auto and manual) — so your manual span automatically gets RED metrics in Grafana.
You're done — test by...¶
cd ebit-api
npm run start:dev
# Trigger the code path with the new span
# Open http://localhost:16686 (Jaeger) → Service: ebit-api
# Search for your span name → verify it appears as a child of the HTTP root
# Open http://localhost:3003 (Grafana) → Service Overview dashboard
# Your span's latency/rate/error metrics are automatically available