← All posts
·14 min read

Claude Code with Datadog: Observability Without Bill Shock

Claude CodeDatadogObservabilityDevOps
Claude Code with Datadog: Observability without the bill shock

Why Datadog without CLAUDE.md generates instrumentation that triples your bill

Datadog is the observability platform most engineering teams converge on once their stack outgrows free-tier providers. APM, logs, metrics, RUM, profiling, and synthetics all share the same query language and the same tag dimensions, which makes cross-signal investigation faster than the alternatives. The pricing is also the most aggressive in the space, and the cost model rewards careful instrumentation and punishes lazy instrumentation hard.

The trap is that Claude Code without explicit constraints generates Datadog instrumentation that looks fine in development and produces eye-watering bills in production. The most common patterns Claude produces: custom metrics tagged with user IDs (every unique user becomes a unique metric series, and Datadog bills per unique series), log lines emitted at INFO level for events that should be DEBUG, trace tags that include the full request body, missing trace ID correlation between logs and APM (so the "view related logs" link in the trace UI returns nothing), no sampling configuration (so every trace in a high-volume service is captured at 100%), and RUM session replay enabled globally instead of being scoped to error sessions.

This guide covers the CLAUDE.md configuration that locks Claude Code into the Datadog cost model: low-cardinality tags, structured logging with trace correlation, head-based sampling for high-volume services, and RUM scoped to the sessions you actually want to replay. For the broader application observability context, Claude Code with Sentry covers the error-tracking layer that pairs with Datadog APM, and Claude Code with PostHog shows the product analytics integration that complements infrastructure metrics.

The Datadog CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Datadog integration it needs to declare: the agent version, the language SDK, the tag taxonomy, the metric naming convention, the log correlation pattern, the sampling rules, and the hard rules that block the high-cost mistakes Claude makes most often.

# Datadog observability rules

## Stack
- dd-trace ^5.x (Node.js APM SDK)
- @datadog/browser-rum ^5.x (RUM SDK)
- @datadog/browser-logs ^5.x (browser log collector)
- Datadog Agent v7.x (running on host or as DaemonSet in k8s)
- DD_API_KEY in environment (server) or clientToken (browser)
- DD_SITE for non-US1 (e.g. datadoghq.eu, us3.datadoghq.com)

## Project structure
- src/tracer.ts              , dd-trace initialisation (FIRST import in entry)
- src/lib/metrics.ts         , statsd client and metric helpers
- src/lib/logger.ts          , pino or winston with trace correlation
- src/instrumentation.ts     , Next.js / Nuxt instrumentation hook
- public/datadog-init.ts     , browser RUM and logs setup

## Tag taxonomy (LOW CARDINALITY ONLY)
- env: dev | staging | prod         , controlled, ~3 values
- service: <service-name>           , controlled, ~10 values
- version: <git sha or semver>      , bounded by release cadence
- region: us-east-1 | eu-west-1     , controlled
- team: backend | frontend | infra  , controlled

## Tags FORBIDDEN on metrics and trace spans
- user_id (unbounded)
- session_id (unbounded)
- email (unbounded)
- order_id (unbounded)
- request_id (unbounded)
- url path with IDs (use route patterns: /users/:id NOT /users/12345)

Use these as LOG attributes (free) or trace TAGS (limited cardinality concern)
but NEVER as metric tags.

## Metric naming
- Format: <namespace>.<service>.<measurement>
- Use dots as separators, lowercase, no spaces
- Counter: claudify.checkout.created
- Gauge: claudify.queue.depth
- Histogram: claudify.api.latency
- Submit via dogstatsd UDP (default port 8125)

## Log correlation (MANDATORY)
- Every log line MUST include dd.trace_id and dd.span_id when a trace exists
- Use the official log injection: tracer.inject() OR pino-datadog-trace
- Format logs as JSON for the Agent to parse (NEVER plain text in production)
- Required fields: timestamp, level, message, dd.service, dd.env, dd.version

## Sampling rules
- Default: head-based sampling at 10% for high-volume services
- Override for low-volume services: 100%
- ALWAYS sample errors at 100% with a forced sampling rule
- NEVER set DD_TRACE_SAMPLE_RATE without considering monthly span volume

## Hard rules
- NEVER tag metrics with high-cardinality dimensions (user_id, request_id, email)
- NEVER log full request bodies at INFO level
- NEVER initialise dd-trace AFTER importing app code (must be first import)
- NEVER use console.log in production (logs without level/structure)
- NEVER enable RUM session replay globally, scope to error sessions
- ALWAYS verify dd.trace_id appears in logs before deploying to prod

Three rules here prevent the majority of bill explosions Claude generates without them.

The low-cardinality tag rule is the single most expensive instrumentation pattern to get wrong. Datadog's custom metrics pricing scales with the number of unique tag combinations (metric "series"). A metric tagged with env, service, and version produces a manageable number of series. Add user_id and the series count explodes to the number of distinct users. A million-user app with one metric tagged by user generates a million series for that one metric. The CLAUDE.md rule lists the dimensions Claude must not put on metrics, and lists where to put them instead (log attributes, where cardinality does not affect price).

The log correlation rule is what makes Datadog worth its price. The promise of "click a trace, see the logs" only works if every log line carries the trace ID of the in-flight span. Claude generates logs that include userId and requestId but not dd.trace_id. The trace and log UIs then show data that cannot be cross-referenced. The rule forces Claude to wire log correlation at initialisation time, not as a per-log-line concern.

The tracer initialisation order rule prevents the most common Node.js instrumentation bug. dd-trace works by monkey-patching require to wrap imported modules with tracing. If you import express before initialising the tracer, dd-trace never patches it and your Express routes are invisible to APM. The rule forces the tracer import to be the absolute first line of your entrypoint.

Install and tracer setup

Install dd-trace:

npm i dd-trace

Create the tracer initialisation file as the first thing your application loads:

// src/tracer.ts
import tracer from 'dd-trace';

tracer.init({
  service: 'claudify-api',
  env: process.env.DD_ENV || process.env.NODE_ENV || 'development',
  version: process.env.GIT_SHA || process.env.npm_package_version,
  logInjection: true,
  runtimeMetrics: true,
  profiling: process.env.NODE_ENV === 'production',
  sampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
});

// Custom sampling rules
tracer.use('express', {
  hooks: {
    request: (span, req) => {
      // 100% sampling for errors and slow requests
      if ((req as any).statusCode >= 500) {
        span?.setTag('manual.keep', true);
      }
    },
  },
});

export default tracer;

Import this file before any other application code:

// src/index.ts
import './tracer';   // MUST be first
import express from 'express';
import { setupRoutes } from './routes';

const app = express();
setupRoutes(app);
app.listen(3000);

The comment // MUST be first matters more than it looks. If Claude adds an import above it during a refactor (a logger, a config loader, a polyfill), the tracer fails silently and every span goes uncaptured. The CLAUDE.md rule about initialisation order, combined with this comment as a sentinel, makes the constraint visible in the source.

For Next.js specifically, the App Router instrumentation hook is the supported initialisation point:

// src/instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    await import('./tracer');
  }
}

Add this hook to next.config.js:

module.exports = {
  experimental: {
    instrumentationHook: true,
  },
};

Add an initialisation section to CLAUDE.md:

## Tracer initialisation

- src/tracer.ts called BEFORE any other import in src/index.ts
- For Next.js: src/instrumentation.ts with the experimental.instrumentationHook flag
- service, env, version are required tracer.init() arguments
- logInjection: true wires dd.trace_id into stdlib logging
- runtimeMetrics: true captures Node.js heap, event loop lag, GC pauses
- profiling: true ONLY in production (CPU overhead in development)
- sampleRate: 0.1 in production, 1.0 in dev/staging

Logging with trace correlation

Datadog correlates logs and traces by matching dd.trace_id between them. The official dd-trace SDK auto-injects this into the stdlib console and into popular loggers (pino, winston, bunyan) when logInjection: true is set.

Pino configuration with trace correlation:

// src/lib/logger.ts
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label }),
    bindings: () => ({
      service: 'claudify-api',
      env: process.env.DD_ENV || process.env.NODE_ENV,
      version: process.env.GIT_SHA,
    }),
  },
  timestamp: pino.stdTimeFunctions.isoTime,
  mixin: () => {
    // dd-trace auto-injects dd.trace_id and dd.span_id here
    return {};
  },
});

Use the logger consistently:

import { logger } from '@/lib/logger';

export async function createOrder(userId: string, orderId: string) {
  // log attributes are FREE, use them liberally
  logger.info({ userId, orderId, action: 'create_order' }, 'Order created');

  try {
    await processPayment(orderId);
    logger.info({ orderId }, 'Payment processed');
  } catch (error) {
    logger.error({ orderId, err: error }, 'Payment failed');
    throw error;
  }
}

The output JSON contains dd.trace_id and dd.span_id whenever a trace is in flight. The Datadog Agent ingests the JSON, parses the fields, and links the log line to the corresponding trace in APM.

To verify correlation works in development, run the app, hit an endpoint, and check the log output:

curl http://localhost:3000/api/orders

The log line should contain:

{
  "level": "info",
  "time": "2026-05-22T10:00:00.000Z",
  "service": "claudify-api",
  "env": "development",
  "dd": {
    "trace_id": "1234567890",
    "span_id": "9876543210"
  },
  "msg": "Order created",
  "userId": "user_abc",
  "orderId": "order_xyz"
}

Add a logging section to CLAUDE.md:

## Logging

- pino or winston with structured JSON output
- service, env, version in every log line (set via logger config, not per-call)
- dd.trace_id and dd.span_id auto-injected by dd-trace when logInjection: true
- Log attributes (userId, orderId, etc.) are FREE, use them for context
- Log level discipline: DEBUG for high-volume, INFO for business events, WARN/ERROR for actionable
- NEVER log secrets, NEVER log full request/response bodies at INFO level
- NEVER use console.log in production, NEVER use plain-text logs

Get Claudify. The CLAUDE.md template Datadog developers use to keep their observability bills sane.

Custom metrics with dogstatsd

Custom metrics let you track business-level events: orders created, signups completed, payments succeeded. Datadog charges by the number of unique series, so the tag dimensions matter as much as the metric name.

A dogstatsd client wrapper:

// src/lib/metrics.ts
import StatsD from 'hot-shots';

const client = new StatsD({
  host: process.env.DD_AGENT_HOST || 'localhost',
  port: 8125,
  prefix: 'claudify.',
  globalTags: {
    env: process.env.DD_ENV || 'dev',
    service: 'claudify-api',
    version: process.env.GIT_SHA || 'unknown',
  },
  errorHandler: (error) => {
    console.error('[statsd]', error);
  },
});

export const metrics = {
  // Counters: increment by 1 per event
  increment: (name: string, tags?: Record<string, string>) => {
    client.increment(name, 1, tagsToArray(tags));
  },

  // Gauges: instantaneous value (queue depth, active connections)
  gauge: (name: string, value: number, tags?: Record<string, string>) => {
    client.gauge(name, value, tagsToArray(tags));
  },

  // Histograms: distribution of values (latency, payload size)
  histogram: (name: string, value: number, tags?: Record<string, string>) => {
    client.histogram(name, value, tagsToArray(tags));
  },

  // Distribution: like histogram but with global percentile accuracy
  distribution: (name: string, value: number, tags?: Record<string, string>) => {
    client.distribution(name, value, tagsToArray(tags));
  },
};

function tagsToArray(tags?: Record<string, string>): string[] {
  if (!tags) return [];
  return Object.entries(tags).map(([k, v]) => `${k}:${v}`);
}

Using the metrics client:

import { metrics } from '@/lib/metrics';

export async function processCheckout(orderId: string, amountCents: number, plan: string) {
  // BAD: orderId is high cardinality
  // metrics.increment('checkout.completed', { orderId, plan });

  // GOOD: plan is low cardinality (~5 values), orderId goes in logs only
  metrics.increment('checkout.completed', { plan });
  metrics.distribution('checkout.amount_cents', amountCents, { plan });

  logger.info({ orderId, amountCents, plan }, 'Checkout completed');
}

The contrast is worth absorbing. orderId belongs in the log line where it costs nothing. plan belongs in the metric tag where it adds one dimension across ~5 known values. Mixing the two on the metric side is what triples the bill.

Allowed and forbidden tags as a quick reference:

Tag On metrics? On logs? Cost impact
env, service, version, region Yes Yes None (controlled vocabularies)
plan, status, error_type Yes Yes Low (~5-50 values)
user_id, email No Yes High on metrics (unbounded)
order_id, request_id No Yes High on metrics (unbounded)
http.method, http.status_code Yes Yes Low (~10 values)
url path (raw) No Yes High (every unique URL)
route pattern (/users/:id) Yes Yes Low

Add a metrics section to CLAUDE.md:

## Custom metrics

- All metric names prefixed with claudify. (or your namespace)
- Counter: metrics.increment('checkout.completed', { plan: 'pro' })
- Histogram/distribution: numeric value as second argument
- ONLY low-cardinality tags on metrics (env, service, status, plan, etc.)
- High-cardinality data (user_id, order_id) goes in logs, NEVER in metric tags
- Each new metric series adds to billable count, review before adding new tags
- Use distribution() over histogram() if you need accurate global percentiles

APM tracing and custom spans

dd-trace auto-instruments most popular libraries: Express, Fastify, Next.js, Postgres, Redis, MongoDB, HTTP clients, AWS SDK. Auto-instrumentation captures the spans, but you often need custom spans around business operations that span multiple library calls.

Creating a custom span:

import tracer from 'dd-trace';

export async function reconcileOrder(orderId: string) {
  return tracer.trace('order.reconcile', { resource: 'reconcileOrder' }, async (span) => {
    span?.setTag('order.id', orderId);

    const order = await fetchOrder(orderId);    // auto-traced DB call
    const payment = await fetchPayment(order.paymentId);  // auto-traced
    const reconciled = await applyReconciliation(order, payment);  // your code

    span?.setTag('order.amount', order.amountCents);
    span?.setTag('order.reconciled', reconciled);

    return reconciled;
  });
}

The tracer.trace() wrapper creates a child span under whatever parent span is active in the current async context. dd-trace propagates the parent across async boundaries automatically using AsyncLocalStorage, so you do not need to thread the span object through your function signatures.

For long-running background jobs that lack an HTTP parent, start a root span explicitly:

async function nightlyReconciliation() {
  await tracer.trace('cron.nightly_reconciliation', async (span) => {
    span?.setTag('cron.name', 'nightly_reconciliation');
    span?.setTag('manual.keep', true);   // force-include in sampling

    const orders = await fetchUnreconciledOrders();
    for (const order of orders) {
      await reconcileOrder(order.id);
    }
  });
}

The manual.keep tag tells the trace agent to retain this trace regardless of the sampling rate. For cron jobs you almost always want this because the trace is rare (one per night) and valuable when it happens (a failure is what you debug).

Add a tracing section to CLAUDE.md:

## Custom spans

- Use tracer.trace(operationName, options, fn) for business operations
- Naming: lowercase, dot-separated: order.reconcile, payment.charge
- Set tags via span?.setTag('key', value) inside the callback
- Tag values: strings, numbers, booleans (NOT objects, NOT arrays)
- For background jobs: tag with manual.keep=true to bypass sampling
- NEVER use deprecated tracer.startSpan() / span.finish() API, use tracer.trace()
- async/await works inside tracer.trace, the span auto-finishes when fn returns

For the broader async context propagation pattern, Claude Code with NestJS shows how to integrate dd-trace with framework-level interceptors.

RUM and browser logs

Real User Monitoring (RUM) and browser logs run client-side. They use a separate SDK and a clientToken instead of an API key. The clientToken is public (it ships in the browser bundle), so it has restricted permissions.

Initialise RUM in your app entry:

// public/datadog-init.ts
import { datadogRum } from '@datadog/browser-rum';
import { datadogLogs } from '@datadog/browser-logs';

datadogRum.init({
  applicationId: process.env.NEXT_PUBLIC_DD_APP_ID!,
  clientToken: process.env.NEXT_PUBLIC_DD_CLIENT_TOKEN!,
  site: 'datadoghq.eu',
  service: 'claudify-web',
  env: process.env.NEXT_PUBLIC_DD_ENV,
  version: process.env.NEXT_PUBLIC_GIT_SHA,
  sessionSampleRate: 100,
  sessionReplaySampleRate: 0,
  trackUserInteractions: true,
  trackResources: true,
  trackLongTasks: true,
  defaultPrivacyLevel: 'mask-user-input',
});

datadogLogs.init({
  clientToken: process.env.NEXT_PUBLIC_DD_CLIENT_TOKEN!,
  site: 'datadoghq.eu',
  service: 'claudify-web',
  env: process.env.NEXT_PUBLIC_DD_ENV,
  forwardErrorsToLogs: true,
  sessionSampleRate: 100,
});

// Opt into session replay only when something interesting happens
window.addEventListener('error', () => {
  datadogRum.startSessionReplayRecording();
});

The sessionReplaySampleRate: 0 default and the error-triggered startSessionReplayRecording() call together implement the "replay only on error" pattern. Session replays are the most expensive RUM feature. Recording every session is rarely worth the cost. Recording sessions that hit an error is almost always worth it because that is what you actually need to debug.

Add a RUM section to CLAUDE.md:

## RUM and browser logs

- @datadog/browser-rum and @datadog/browser-logs (separate from dd-trace)
- clientToken (NOT API key) goes in NEXT_PUBLIC_DD_CLIENT_TOKEN, public OK
- applicationId in NEXT_PUBLIC_DD_APP_ID
- sessionReplaySampleRate: 0 by default, opt in via startSessionReplayRecording()
- defaultPrivacyLevel: 'mask-user-input' (do NOT use 'allow' in production)
- forwardErrorsToLogs: true to capture unhandled errors as log events
- Initialise as early as possible, ideally in a top-level layout

Common Claude Code mistakes with Datadog

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. High-cardinality metric tags

Claude generates: metrics.increment('order.created', { userId, orderId }).

Correct pattern: metrics.increment('order.created', { plan }) with userId and orderId in the log line.

2. Importing app code before tracer

Claude generates: import express from 'express'; import tracer from 'dd-trace'; tracer.init();

Correct pattern: import './tracer'; import express from 'express'; with tracer.init() inside tracer.ts as the first thing.

3. console.log in production

Claude generates: console.log('Order created', { orderId, userId });

Correct pattern: logger.info({ orderId, userId }, 'Order created'); with the pino/winston logger configured for JSON output.

4. No sampling configuration

Claude generates: tracer.init({ service, env, version }); with no sampleRate.

Correct pattern: sampleRate: 0.1 in production with a forced manual.keep for errors.

5. RUM session replay always on

Claude generates: datadogRum.init({ sessionReplaySampleRate: 100, ... });

Correct pattern: sessionReplaySampleRate: 0 with startSessionReplayRecording() triggered by error events.

6. Plain text logs

Claude generates: console.log('User ' + userId + ' did ' + action);

Correct pattern: structured JSON via pino/winston with trace ID injection. The Datadog Agent parses JSON natively. Plain text logs need a custom pipeline rule for every field you want to query.

Add a common mistakes section to CLAUDE.md with these six pairs. The before/after format helps Claude match what it would otherwise generate to the corrected form.

Permission hooks for Datadog operations

A Datadog integration accumulates scripts: dashboard sync, monitor CRUD, log archive queries, RUM session exports. Some are read-only. Some can wake on-call engineers. Permission hooks gate the noisy operations.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(node scripts/list-monitors.js*)",
      "Bash(node scripts/query-logs.js*)",
      "Bash(curl https://api.datadoghq.eu/api/v1/metric*)"
    ],
    "deny": [
      "Bash(node scripts/create-monitor.js*)",
      "Bash(node scripts/delete-dashboard.js*)",
      "Bash(node scripts/silence-monitor.js*)",
      "Bash(node scripts/restore-archived-logs.js*)"
    ]
  }
}

Listing monitors and querying logs are safe and useful for investigation. Creating monitors, deleting dashboards, and silencing alerts (which can hide real incidents) require explicit confirmation. Restoring archived logs is denied because it incurs significant per-GB cost.

Building Datadog integrations that observe everything without breaking the bank

The Datadog CLAUDE.md in this guide produces instrumentation where the tracer initialises before any other import, custom metrics avoid high-cardinality dimensions, logs are structured JSON with trace ID correlation, sampling is configured per environment with forced retention for errors, RUM session replay is scoped to error sessions, and destructive scripts require explicit confirmation.

The underlying principle is the same as any platform with usage-based pricing. Datadog without a CLAUDE.md produces instrumentation that looks comprehensive (every action tagged, every session replayed, every trace captured) and produces bills that scale faster than revenue. The CLAUDE.md template removes each pricing failure mode by making the cost-aware pattern the only pattern Claude can generate.

For the broader observability picture, Datadog works alongside Claude Code with Sentry for the error tracking layer, and Claudify includes a Datadog-specific CLAUDE.md template with the tag taxonomy, sampling rules, log correlation patterns, RUM scoping logic, and all six common-mistake rules pre-configured.

Get Claudify. The CLAUDE.md template Datadog customers use to keep observability bills proportional to actual product value.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame