Skip to main content
This guide walks through adding production-grade observability to your FrontMCP server — from zero-config tracing to connecting Coralogix, Datadog, or any OTLP-compatible backend.
Prerequisites: A working FrontMCP server with at least one tool. See Your First Tool if you need to get started.

What You’ll Get

  • Automatic spans for every tool call, resource read, auth flow, and HTTP request
  • Structured JSON logs with trace correlation (trace_id, span_id)
  • this.telemetry API for custom spans in your tools and plugins
  • Per-request log aggregation

Step 1: Install

npm install @frontmcp/observability
This installs the observability package with @opentelemetry/api as the only hard dependency (~50KB). OTel SDK packages are optional peers — install only what you need.

Step 2: Enable Observability

Add the observability field to your @FrontMcp config:
import { FrontMcp } from '@frontmcp/sdk';

@FrontMcp({
  info: { name: 'my-server', version: '1.0.0' },
  apps: [MyApp],
  observability: true, // Enable tracing with all defaults
})
export default class Server {}
That’s it. Every flow is now instrumented. Without a TracerProvider configured, all OTel calls are no-ops with zero overhead.
For fine-grained control, pass an object instead of true:
observability: {
  tracing: {
    httpSpans: true,
    executionSpans: true,
    fetchSpans: true,
    flowStageEvents: true,
    transportSpans: true,
    authSpans: true,
  },
  logging: true,
  requestLogs: true,
}

Step 3: Configure a Backend

See spans printed to your terminal — great for development:
import { setupOTel } from '@frontmcp/observability';

setupOTel({
  serviceName: 'my-server',
  exporter: 'console',
});

Step 4: Use this.telemetry in Tools

Every execution context (tools, resources, prompts, agents) gets a this.telemetry API when observability is enabled. No imports, no context construction — it just works.
import { Tool, ToolContext } from '@frontmcp/sdk';
import { z } from 'zod';

@Tool({
  name: 'search',
  description: 'Search documents',
  inputSchema: { query: z.string() },
})
class SearchTool extends ToolContext<typeof SearchTool> {
  async execute({ query }: { query: string }) {
    // Add events to the current tool execution span
    this.telemetry.addEvent('query-parsed', { terms: query.split(' ').length });

    // Create a child span for a specific operation
    const results = await this.telemetry.withSpan('database-query', async (span) => {
      span.setAttribute('db.query', query);
      const data = await this.get(SearchService).search(query);
      span.addEvent('rows-returned', { count: data.length });
      return data;
    });

    // Set attributes on the tool execution span
    this.telemetry.setAttributes({ 'results.count': results.length });

    return { results };
  }
}
This produces:
tool search                          (auto-created by hooks)
  |-- event: query-parsed            (from this.telemetry.addEvent)
  |-- attribute: results.count=42    (from this.telemetry.setAttributes)
  |
  |-- database-query                 (child span from this.telemetry.withSpan)
  |     |-- attribute: db.query=...
  |     |-- event: rows-returned

Step 5: Structured Logging

When logging is enabled, every this.logger.info() call produces a structured JSON entry with automatic trace correlation:
{
  "timestamp": "2026-04-03T10:15:30.123Z",
  "level": "info",
  "severity_number": 9,
  "message": "processing user request",
  "trace_id": "abcdef1234567890abcdef1234567890",
  "span_id": "1234567890abcdef",
  "request_id": "req-uuid-001",
  "session_id_hash": "a3f8b2c1d4e5f6a7",
  "scope_id": "my-app",
  "flow_name": "tools:call-tool",
  "elapsed_ms": 42,
  "attributes": { "userId": 123 }
}
Configure redaction for sensitive fields:
observability: {
  logging: {
    sinks: [{ type: 'stdout' }],
    redactFields: ['password', 'token', 'secret', 'authorization'],
    includeStacks: process.env.NODE_ENV !== 'production',
  },
}

Step 6: Request Log Collection

Enable requestLogs to get a complete aggregated view of each request:
observability: {
  requestLogs: {
    maxEntries: 500,
    onRequestComplete: async (log) => {
      // log.request_id, log.trace_id, log.tool_name
      // log.duration_ms, log.status, log.entries[]
      await myStore.save(log);
    },
  },
}

Step 7: Testing with Telemetry

Use the built-in testing utilities to verify your spans:
import { createTestTracer, assertSpanExists, assertSpanAttribute }
  from '@frontmcp/observability';

describe('SearchTool', () => {
  const { tracer, exporter, cleanup } = createTestTracer();

  afterEach(() => exporter.reset());
  afterAll(() => cleanup());

  it('should create a database-query child span', async () => {
    // ... invoke tool ...

    const spans = exporter.getFinishedSpans();
    const dbSpan = assertSpanExists(spans, 'database-query');
    assertSpanAttribute(dbSpan, 'db.query', 'test query');
  });
});

Span Hierarchy

Every request produces a span tree like this:
HTTP Server Span: "POST /mcp"
  |-- event: stage.traceRequest
  |-- event: stage.acquireQuota
  |-- event: stage.checkAuthorization
  |-- event: stage.router
  |
  |-- RPC Span: "tools/call"
  |     |-- rpc.system = "mcp"
  |     |-- mcp.session.id = "a3f8b2c1..."
  |     |-- event: stage.parseInput
  |     |-- event: stage.findTool
  |     |-- event: stage.validateInput
  |     |
  |     |-- Tool Span: "tool get_weather"
  |     |     |-- mcp.component.type = "tool"
  |     |     |-- enduser.id = "client-42"
  |     |     |-- event: stage.execute.start
  |     |     |
  |     |     |-- HTTP Client Span: "GET" (from ctx.fetch)
  |     |     |     |-- url.full = "https://api.weather.com/..."
  |     |     |     |-- http.response.status_code = 200
  |     |     |
  |     |     |-- event: stage.execute.done
  |     |
  |     |-- event: stage.validateOutput
  |     |-- event: stage.finalize
  |
  |-- event: stage.finalize

Attributes Reference

MCP Protocol Attributes (interoperable)

AttributeExampleDescription
mcp.method.nametools/callMCP protocol method
mcp.session.ida3f8b2c1d4e5f6a7Hashed session ID
mcp.resource.urifile:///data.txtResource URI
mcp.component.typetoolComponent type: tool, resource, prompt, agent
mcp.component.keytool:get_weatherFully qualified component key

Standard OTel Attributes

AttributeExampleDescription
rpc.systemmcpRPC system identifier
rpc.servicemy-serverServer name
rpc.methodtools/callRPC method
http.request.methodPOSTHTTP method
http.response.status_code200HTTP status
enduser.idclient-42Client ID from auth token
enduser.scoperead write adminOAuth scopes

FrontMCP Vendor Attributes

AttributeDescription
frontmcp.scope.idScope identifier
frontmcp.request.idUnique request ID
frontmcp.tool.nameTool name
frontmcp.tool.ownerTool owner class
frontmcp.flow.nameFlow name (e.g., tools:call-tool)
frontmcp.transport.typeTransport: legacy-sse, streamable-http
frontmcp.auth.modeAuth mode: public, transparent, orchestrated
frontmcp.session.id_hashPrivacy-safe session hash

Best Practices

Don’t create spans with raw OTel API. Use this.telemetry.withSpan() or this.telemetry.startSpan() — they automatically inherit the trace context and add base attributes.
this.telemetry.addEvent('step-done') is cheaper than this.telemetry.startSpan('step'). Use events for milestones, spans for timed operations.
Always configure redactFields in production: ['password', 'token', 'secret', 'authorization', 'cookie'].
The otlp sink type works with all major platforms (Coralogix, Datadog, Logz.io, Grafana). Don’t build vendor-specific integrations.
The auto-instrumentation covers all SDK flows. Only add this.telemetry spans for your business logic operations (API calls, database queries, complex processing).

Next Steps

Telemetry API Reference

Full API docs for TelemetryAccessor, TelemetrySpan, and testing utilities

Rate Limiting

Add rate limiting alongside observability for production readiness