Skip to content

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

import { trace, SpanStatusCode } from '@opentelemetry/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' (matches service.name in pre-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 (not startSpan) 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