● LIVE· № 001 · SANITIZE EVERYTHING THAT HITS GIT: CLEANING PUBLIC REPOS FROM IDENTITY LEAKS · 2026.05.11· № 002 · IMAGEGEN-MCP: A HOMEGROWN MCP SERVER FOR BLOG COVERS · 2026.05.11· № 003 · SMART PASTE: STRIPPING TERMINAL NOISE BEFORE PASTING, WITH ONE HOTKEY · 2026.05.10· № 004 · CLAUDE CODE TEAM TELEMETRY: CENTRALIZED USAGE STATS · 2026.05.07· № 005 · CLAUDE CODE ONBOARDING GUIDE FOR NEWCOMERS · 2026.05.06· 11 POSTS · 0 DRAFTS
EN / RU
·22 MIN

Claude Code team telemetry: centralized usage stats

How to collect Claude Code OpenTelemetry metrics and events to your own backend, store them in a relational DB, and run SQL reports on cost, productivity, and errors.

Goal. Collect centralized Claude Code usage stats across the team on a single server: cost, productivity, anomalies.

Plan. Claude.ai Teams (gives server-managed settings via ~/.claude/remote-settings.json).

Source. Built-in OpenTelemetry export from Claude Code → OTLP/HTTP JSON endpoint in any backend service → relational DB.

Pragmatic path. Take an existing backend service (domain, TLS, CI/CD, DB already in place) and add two endpoints to it. No separate infra (Collector / Prometheus / Grafana / Docker / nginx) on day one. Heavy stack — optional extension later.


1. Architecture

1.1 MVP — backend endpoint + DB

┌────────────────────────┐         ┌──────────────────────────────────┐
│  Dev laptop × N        │         │  Existing backend service        │
│                        │         │                                  │
│  Claude Code           │ OTLP    │                                  │
│   ├─ metrics ──────────┼─ HTTPS ─┼─► POST /otel/v1/metrics          │
│   └─ logs/events ──────┼─ JSON ──┼─► POST /otel/v1/logs             │
│                        │ Bearer  │       │                          │
│  remote-settings.json  │◄── pull │       ▼                          │
│  (env block, hourly)   │ claude.ai  ┌───────────────────────────┐   │
└────────────────────────┘         │  │ DB: claude_code_*         │   │
                                   │  │  - otlp_raw (JSON blob)   │   │
                                   │  │  - metric_points (parsed) │   │
                                   │  │  - events (parsed)        │   │
                                   │  └───────────────────────────┘   │
                                   │       │                          │
                                   │       │ scheduled parser 1/min   │
                                   │       ▼                          │
                                   │  reports: SQL / Metabase / Grafana│
                                   └──────────────────────────────────┘

Server stack doesn't matter — Node, Python, Go, Java, Rust, whatever you have. All you need: an HTTP endpoint with a Bearer check, an INSERT into the DB, and a periodic parser job.

1.2 Heavy alt — separate OTel Collector + Prometheus + Grafana

Optional later if the DB can't keep up or you want ready-made dashboards.

Dev laptop × N ──OTLP/gRPC──► OTel Collector ──► Prometheus ──► Grafana

1.3 Key decisions

  • JSON, not protobuf. Claude Code supports OTEL_EXPORTER_OTLP_PROTOCOL=http/json — no protobuf dependencies on the server.
  • Push, not pull. Each laptop pushes OTLP/HTTP. We don't open ports on laptops.
  • One Bearer token for the whole team. Per-user tokens aren't needed — user.email comes from the claude.ai OAuth.
  • Raw storage + lazy parser. First the JSON goes into otlp_raw, then a scheduled job spreads it into typed tables. The OTLP schema can change — raw stays as an audit trail.
  • Laptop config via the Teams admin console. One JSON, reaches everyone within an hour, no bootstrap scripts.

2. What gets collected

2.1 Resource attributes (on every metric/event)

AttributeDescriptionControl
service.name"claude-code"always
service.version / app.versionCLI versionOTEL_METRICS_INCLUDE_VERSION
os.type, os.version, host.archOS + CPUalways
wsl.versiononly on WSLauto
session.idsession UUIDOTEL_METRICS_INCLUDE_SESSION_ID (default on)
organization.idTeams organization IDon auth
user.account_uuid, user.account_idAnthropic account UUIDOTEL_METRICS_INCLUDE_ACCOUNT_UUID (default on)
user.idanonymous device IDalways
user.emailOAuth emailon OAuth login
terminal.typeiTerm / vscode / cursor / tmuxauto
prompt.idUUID, ties events of one promptevents only
workspace.host_pathspaths from desktop appevents only

You can attach custom labels (team, environment, cost_center, etc.) via OTEL_RESOURCE_ATTRIBUTES.

2.2 Metrics (8)

MetricTypeUnitTriggerAttributes
claude_code.session.countcountercountsession startstart_type: fresh/resume/continue
claude_code.lines_of_code.countcountercountcode edittype: added/removed
claude_code.pull_request.countcountercountPR created—
claude_code.commit.countcountercountgit commit—
claude_code.cost.usagecounterUSDeach API requestmodel, query_source, speed, effort
claude_code.token.usagecountertokenseach API requesttype, model, query_source, speed, effort
claude_code.code_edit_tool.decisioncountercountaccept/reject edittool_name, decision, source, language
claude_code.active_time.totalcountersecuser/CLI activitytype: user/cli

2.3 Events / Logs (18)

Every event carries event.name, event.timestamp, event.sequence, prompt.id, workspace.host_paths plus the resource attrs.

EventTriggerKey attributes
claude_code.user_promptuser submitted a promptprompt_length, command_name; prompt only with OTEL_LOG_USER_PROMPTS=1
claude_code.tool_resulttool finishedtool_name, tool_use_id, success, duration_ms, error_type, I/O sizes
claude_code.api_requestsuccessful API callmodel, cost_usd, duration_ms, tokens, request_id, speed, effort
claude_code.api_errorAPI failmodel, error, status_code, duration_ms, attempt
claude_code.api_retries_exhaustedfinal fail after retriestotal_attempts, total_retry_duration_ms
claude_code.api_request_bodyonly with OTEL_LOG_RAW_API_BODIESfull conversation with the model
claude_code.api_response_bodyonly with OTEL_LOG_RAW_API_BODIESmodel response
claude_code.tool_decisionaccept/reject permissiontool_name, decision, source
claude_code.permission_mode_changedmode changefrom_mode, to_mode, trigger
claude_code.auth/login or /logoutaction, success, auth_method
claude_code.mcp_server_connectionMCP connect/disconnect/failstatus, transport_type, duration_ms
claude_code.internal_errorinternal exceptionerror_name, error_code (no message/stack)
claude_code.plugin_installedplugin installedmarketplace.is_official, install.trigger
claude_code.skill_activatedskill invokedskill.name, invocation_trigger
claude_code.at_mentionresolved @mentionmention_type, success
claude_code.hook_execution_starthooks startedhook_event, hook_name, num_hooks
claude_code.hook_execution_completehooks finished+ num_success, num_blocking, total_duration_ms
claude_code.compactioncontext compactiontrigger, success, pre_tokens, post_tokens

2.4 Traces (beta)

Enabled via CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1. Spans: tool execution, API request, hook execution. Not needed for the baseline task — large volume, metrics and events are enough.

2.5 Reference


3. PII and privacy

What goes out by default

  • Metrics and events without content: lengths, durations, counters, operation types, tokens, cost, model and tool names.
  • user.email — primary user identifier.
  • user.account_uuid, user.id, organization.id, session.id, prompt.id.
  • Built-in tool and command names (Read, Write, Bash, Edit, etc.).

What does NOT go out without explicit opt-in

DataDefaultEnabled by
Prompt textlength onlyOTEL_LOG_USER_PROMPTS=1
Tool args (Bash commands, URLs, patterns)—OTEL_LOG_TOOL_DETAILS=1 (~4 KB cap)
File pathsin tool eventsOTEL_LOG_TOOL_DETAILS=1
File contents / Bash output—OTEL_LOG_TOOL_CONTENT=1 (60 KB cap, traces only)
API request body—OTEL_LOG_RAW_API_BODIES=1
API response body—OTEL_LOG_RAW_API_BODIES
Custom skill/plugin namescollapsedOTEL_LOG_TOOL_DETAILS=1
Extended-thinkingalways redactedcan't be enabled

Consent is not requested by Claude Code. The config applies silently via remote-settings.json. A user only finds out about telemetry by inspecting the file by hand.

So the responsibility is on the admins:

  1. Before rollout, notify the team: what we collect, where it's stored, who has access.
  2. Document it in the onboarding doc.
  3. If OTEL_LOG_TOOL_DETAILS or OTEL_LOG_RAW_API_BODIES is being enabled — a separate explicit announcement, since passwords in Bash args, tokens in URLs, and file contents can leak.

Metadata only. No opt-in flags. Enough for all the business metrics (cost / productivity / adoption / health). PII = user.email only.


4. Developer config (via the Teams admin console)

4.1 Where to edit

claude.ai → Admin → Settings → Claude Code → Managed settings → text field where the JSON gets pasted.

4.2 JSON to push

An env block goes on top of the existing config. The endpoint points at your backend service:

{
  "env": {
    "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
    "OTEL_METRICS_EXPORTER": "otlp",
    "OTEL_LOGS_EXPORTER": "otlp",
    "OTEL_EXPORTER_OTLP_PROTOCOL": "http/json",
    "OTEL_EXPORTER_OTLP_ENDPOINT": "https://<your-backend>/otel",
    "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer REPLACE_WITH_REAL_TOKEN",
    "OTEL_METRIC_EXPORT_INTERVAL": "30000",
    "OTEL_LOGS_EXPORT_INTERVAL": "5000",
    "OTEL_RESOURCE_ATTRIBUTES": "team=backend,environment=prod"
  }
}

Claude Code appends the /v1/metrics and /v1/logs suffixes to OTEL_EXPORTER_OTLP_ENDPOINT itself — the endpoint should point at the /otel prefix only.

4.3 Behavior

  • Lands at ~/.claude/remote-settings.json for each user.
  • Sync: on login + once an hour.
  • Local edits to remote-settings.json are wiped on the next sync.
  • env from server-managed wins over the local ~/.claude/settings.json (higher precedence).

4.4 If the admin console doesn't expose the env field

Alternative — a ~/.claude/settings.json template + onboarding script, or managed-settings.json via MDM. Less convenient, but works (see section 8).


5. Server side

5.1 Load

For a team of ~25:

  • ~25 metric pushes per minute (OTEL_METRIC_EXPORT_INTERVAL=30s)
  • ~25 log pushes every 5s (OTEL_LOGS_EXPORT_INTERVAL=5s)
  • Payload size: 5–50 KB compressed JSON
  • Total: ~5 RPS at peak, ~50 MB/day of raw JSON

Any relational DB (PostgreSQL / MySQL / MariaDB) handles this comfortably; the extra load is minimal.

5.2 Schema — two tables (raw + parsed)

DDL below is MySQL. For Postgres — JSONB instead of JSON, BIGSERIAL instead of BIGINT UNSIGNED AUTO_INCREMENT, TIMESTAMP(3) instead of DATETIME(3). Same semantics.

Raw bucket — dump as-is, parse later:

CREATE TABLE claude_code_otlp_raw (
    id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    received_at  DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    kind         VARCHAR(16) NOT NULL,            -- 'metrics' | 'logs'
    payload      JSON NOT NULL,
    parsed_at    DATETIME(3) NULL,                 -- batch-parser marker
    INDEX idx_received (received_at),
    INDEX idx_unparsed (parsed_at, id)
) ENGINE=InnoDB
  ROW_FORMAT=COMPRESSED
  DEFAULT CHARSET=utf8mb4;

Metrics (typed points):

CREATE TABLE claude_code_metric_points (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    raw_id        BIGINT UNSIGNED NOT NULL,
    ts            DATETIME(3) NOT NULL,
    user_email    VARCHAR(255),
    user_id       VARCHAR(64),
    session_id    VARCHAR(64),
    org_id        VARCHAR(64),
    metric_name   VARCHAR(128) NOT NULL,
    value         DOUBLE NOT NULL,
    model         VARCHAR(64),
    query_source  VARCHAR(32),
    speed         VARCHAR(16),
    effort        VARCHAR(16),
    type          VARCHAR(32),
    INDEX idx_ts (ts),
    INDEX idx_user_metric (user_email, metric_name, ts),
    INDEX idx_metric_ts (metric_name, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Events:

CREATE TABLE claude_code_events (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    raw_id        BIGINT UNSIGNED NOT NULL,
    ts            DATETIME(3) NOT NULL,
    event_name    VARCHAR(64) NOT NULL,
    user_email    VARCHAR(255),
    session_id    VARCHAR(64),
    prompt_id     VARCHAR(64),
    attrs         JSON,
    INDEX idx_ts (ts),
    INDEX idx_event_ts (event_name, ts),
    INDEX idx_user_event (user_email, event_name, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5.3 Endpoint and parser — two components

Implementation in any stack. The logic is identical:

Ingestion endpoint — accepts POST on /otel/v1/metrics and /otel/v1/logs:

  1. Verify the Authorization: Bearer <token> header against the expected one. Mismatch — 401.
  2. INSERT body as-is into claude_code_otlp_raw (kind = metrics or logs).
  3. Reply 200 {}. No parsing on the hot path.

Parser — periodic job (cron / scheduler) every minute:

  1. SELECT N raw rows where parsed_at IS NULL (batch 500–2000).
  2. For each:
    • JSON.parse → walk over resourceMetrics / resourceLogs.
    • For metrics: pull name, value, timestamp from dataPoints + resource and point attributes. INSERT into claude_code_metric_points.
    • For logs: pull event.name, timestamp, attributes. INSERT into claude_code_events (the rest goes into the JSON attrs column).
  3. UPDATE parsed_at = NOW() on the processed rows.

OTLP attributes are encoded as an array [{"key":"...","value":{"stringValue":"..."}}]. A helper flattens this into a Map<String, String> by walking through stringValue / intValue / doubleValue / boolValue.

5.4 TTL — cron deletion of old data

Simplest option — a separate scheduled task in the app itself: hourly DELETE FROM claude_code_otlp_raw WHERE received_at < NOW() - INTERVAL 90 DAY LIMIT 50000. Daily — same for the typed tables with a 365-day horizon.

In MySQL you can use EVENT (event_scheduler = ON); in Postgres — pg_cron. An external cron + psql/mysql works too.

5.5 Rollout — step by step

  1. Generate a token: openssl rand -hex 32 → store in a secret manager.
  2. Apply a migration (Flyway / Liquibase / Alembic / sqlx-migrate) with the three tables.
  3. Add the ingestion endpoint + parser to the app code.
  4. Wire the token into the app config via an env var / secret manager. Never commit it.
  5. Deploy through CI.
  6. Smoke-test from a local machine:
    curl -X POST https://<your-backend>/otel/v1/metrics \
         -H "Authorization: Bearer $TOKEN" \
         -H "Content-Type: application/json" \
         -d '{"resourceMetrics":[]}'
    # expect 200 {}
  7. Enable the env block in the Teams admin console (section 4.2). Within an hour, metrics will start arriving.
  8. Verify after an hour: SELECT COUNT(*), MIN(received_at), MAX(received_at) FROM claude_code_otlp_raw;.

6. Security

ThreatMitigation
Third-party push to the endpointBearer token in Authorization, checked in the endpoint
Traffic interceptionTLS on the domain (or a managed proxy)
Token leakOne shared token, rotation through admin console + reissue in the secret store
DB data accessExisting access controls (read-only role for analytics)
PII leaks in metricsDon't enable OTEL_LOG_* flags without an explicit team announcement
Bash args with secretsIf OTEL_LOG_TOOL_DETAILS is on — filter in the parser (regex-mask tokens / passwords before storing)
DoS on the endpointRate-limit at the app level (token bucket) or in the reverse proxy in front of it

Optional hardening:

  • IP whitelist at the API gateway (if developers are on VPN).
  • Alert on a drop in INSERT RPS into claude_code_otlp_raw over an hour during business hours — a signal of telemetry breakage or mass opt-out.
  • Token rotation: on compromise, generate a new one, update the admin console — within an hour it propagates. Keep the old token valid for one more hour (overlap), then revoke.

7. Reports — SQL

The queries run against claude_code_metric_points and claude_code_events. Driveable from any SQL client (DataGrip, Metabase, Apache Superset, CLI). Ready-made dashboards in Grafana — later, via a MySQL/Postgres datasource.

7.1 Cost

-- total cost over 24h
SELECT ROUND(SUM(value), 2) AS cost_usd
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.cost.usage'
  AND ts >= NOW() - INTERVAL 1 DAY;
 
-- top-10 users by spend over a week
SELECT user_email, ROUND(SUM(value), 2) AS cost_usd
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.cost.usage'
  AND ts >= NOW() - INTERVAL 7 DAY
GROUP BY user_email
ORDER BY cost_usd DESC
LIMIT 10;
 
-- cost by model over 24h
SELECT model, ROUND(SUM(value), 2) AS cost_usd
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.cost.usage'
  AND ts >= NOW() - INTERVAL 1 DAY
GROUP BY model
ORDER BY cost_usd DESC;
 
-- cost by effort level
SELECT effort, ROUND(SUM(value), 2) AS cost_usd
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.cost.usage'
  AND ts >= NOW() - INTERVAL 1 DAY
GROUP BY effort;

7.2 Tokens

-- tokens by type over an hour
SELECT type, SUM(value) AS tokens
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.token.usage'
  AND ts >= NOW() - INTERVAL 1 HOUR
GROUP BY type;
 
-- cache hit ratio
SELECT
    SUM(CASE WHEN type = 'cacheRead' THEN value ELSE 0 END)
    / SUM(CASE WHEN type IN ('input', 'cacheRead') THEN value ELSE 0 END) AS cache_hit_ratio
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.token.usage'
  AND ts >= NOW() - INTERVAL 1 HOUR;

7.3 Productivity

-- lines added/removed per user over a week
SELECT user_email, type, SUM(value) AS lines
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.lines_of_code.count'
  AND ts >= NOW() - INTERVAL 7 DAY
GROUP BY user_email, type
ORDER BY user_email;
 
-- edit accept rate
SELECT
    SUM(CASE WHEN type LIKE 'accept%' THEN value ELSE 0 END)
    / SUM(value) AS accept_rate
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.code_edit_tool.decision'
  AND ts >= NOW() - INTERVAL 1 DAY;

7.4 Engagement

-- active time in minutes per user per day
SELECT user_email, ROUND(SUM(value) / 60) AS minutes
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.active_time.total'
  AND ts >= NOW() - INTERVAL 1 DAY
GROUP BY user_email
ORDER BY minutes DESC;
 
-- DAU over a week
SELECT DATE(ts) AS day, COUNT(DISTINCT user_email) AS dau
FROM claude_code_metric_points
WHERE metric_name = 'claude_code.session.count'
  AND ts >= NOW() - INTERVAL 7 DAY
GROUP BY day
ORDER BY day;

7.5 Reliability

-- API error rate over the last hour
SELECT
    SUM(CASE WHEN event_name = 'claude_code.api_error' THEN 1 ELSE 0 END)
    / COUNT(*) AS error_rate
FROM claude_code_events
WHERE event_name IN ('claude_code.api_request', 'claude_code.api_error')
  AND ts >= NOW() - INTERVAL 1 HOUR;
 
-- top tool errors
SELECT
    JSON_UNQUOTE(JSON_EXTRACT(attrs, '$.tool_name')) AS tool,
    JSON_UNQUOTE(JSON_EXTRACT(attrs, '$.error_type')) AS error,
    COUNT(*) AS cnt
FROM claude_code_events
WHERE event_name = 'claude_code.tool_result'
  AND JSON_EXTRACT(attrs, '$.success') = 'false'
  AND ts >= NOW() - INTERVAL 1 DAY
GROUP BY tool, error
ORDER BY cnt DESC
LIMIT 20;

7.6 Alerts

Implementation — a scheduled task in the app itself or an external cron, sending to a Slack webhook / email.

AlertSQL conditionSeverity
Daily cost spiketoday's cost > 2× the 7-day averagewarning
Per-user cost capany user > $100/daywarning
API error rateerrors > 5% of requests over 15 mincritical
Telemetry silence0 INSERTs into otlp_raw for 1 hour during business hourswarning
Parser stuckunprocessed rows (parsed_at IS NULL) > 10000warning

8. Alternative ways to distribute the config

If the Teams admin console doesn't allow pushing env (UI limited to plugins/permissions only):

8.1 ~/.claude/settings.json via onboarding

A bootstrap script merges the env block into each developer's local ~/.claude/settings.json. Downsides: a developer can delete it, forget, or never run it.

8.2 managed-settings.json via MDM

System path, protected by root permissions:

  • macOS: /Library/Application Support/ClaudeCode/managed-settings.json
  • Linux: /etc/claude-code/managed-settings.json

Rolled out via Jamf / Ansible / Puppet. Can't be overridden locally. Good for strict compliance, requires MDM infra.

8.3 Comparison

MethodEnforcementEffortPer-user tokensRecommendation
Teams admin console (remote-settings.json)strongminimalnofirst choice
MDM (managed-settings.json)maximalhighpossible via templatesif you have MDM
Bootstrap script + ~/.claude/settings.jsonweaklowyesfallback

9. Extensions (when needed)

9.1 Grafana on top of the DB

When you want pretty dashboards:

  1. Stand up Grafana (a separate container or managed).
  2. Add a datasource (MySQL / Postgres) → connect to the existing DB with a read-only role.
  3. Import dashboards (or build them from the SQL in section 7).

No data migration, no OTel Collector, no Prometheus.

9.2 Migration to OTel Collector + Prometheus

If event volume passes ~1M/day or you want ready-made OTel tooling:

Claude Code → OTel Collector (auth) → ClickHouse / Prometheus + Grafana

Your endpoint stays as a fallback / staging. Laptop config in the admin console points to the new endpoint.

9.3 Logs to Loki / a separate log stack

As claude_code_events grows, parsed events can be duplicated into Loki via Vector / Fluent Bit. Better for full-text search over prompt content (if OTEL_LOG_USER_PROMPTS=1 is ever turned on).

9.4 Grafana Cloud as a self-hosted replacement

The free tier (10k series, 50 GB logs) covers ~25 people. Just OTel Collector locally or a direct OTLP push to Grafana Cloud. Downside — data leaves the perimeter, conflicts with compliance.

9.5 Per-user tokens

If you ever need to revoke access pointwise:

  • Generate N tokens, store in Vault / SSM.
  • Onboarding script reads the token and drops it into ~/.claude/settings.json.
  • In the endpoint — validate against a claude_code_telemetry_tokens table (token_hash, user_id, revoked_at).

For a team up to ~30 this is usually overkill. One shared token + rotation on compromise is simpler.

9.6 Enriching with business context

In the parser, when writing into the typed tables, add a JOIN with an internal users table (if the email matches) and stash team, department, cost_center, tenure_days. Useful for breaking ROI down by team.

9.7 Defending against leaks in Bash args

If OTEL_LOG_TOOL_DETAILS=1 is on, in the parser before storage — a regex mask over typical secret patterns (password|token|secret|api[_-]?key|bearer + value → [REDACTED]).


10. Roadmap

StageStepsEstimate
MVPDB migration + ingestion endpoint + parser in the existing backend, env block in admin console0.5 day
Reportsbasic SQL from section 7, Metabase/Superset/Grafana on top of the DB0.5–1 day
Privacy agreementteam announcement, wiki update, description of the data set0.5 day
Alertsscheduled checks + Slack webhook (cost spike, error rate, parser stuck)0.5 day
Extensions (opt.)Grafana dashboards, OTel Collector for scale, JOIN with users for team breakdown1–2 days

Critical path: ~1 working day from start to working SQL reports.


11. Pre-rollout checklist

  • DB migration created (claude_code_otlp_raw, claude_code_metric_points, claude_code_events)
  • Bearer token generated (openssl rand -hex 32) and stored in a secret store
  • Endpoint and parser added to code, covered with unit tests
  • CI deploy succeeded, migration applied
  • Smoke test curl on /otel/v1/metrics returns 200
  • Team notified about telemetry and the data set
  • Admin console updated with the env block (section 4.2)
  • After 1 hour: ≥3 distinct user.email in otlp_raw
  • Parser is keeping up: parsed_at IS NULL count stays low
  • Basic reports from section 7 return non-empty results
  • Alerts wired up (cost spike, error rate, telemetry silence, parser stuck)
  • TTL/cleanup tasks configured

12. Prompt redaction before sending to Anthropic — the UserPromptSubmit hook

A separate task from telemetry. This isn't about what Claude Code sends us via OTel; it's about what the user sends to the Anthropic API. If a chat mentions secrets, client brand names, or internal URLs — they go to the cloud. The hook lets you intercept and edit the prompt before sending.

12.1 Mechanism

UserPromptSubmit — a built-in Claude Code hook that fires between Enter and the API call.

ChannelDescription
stdinJSON: prompt, cwd, session_id, transcript_path
Exit 0the prompt continues as-is
Exit 2the prompt is blocked, stderr is shown to the user as an error
JSON on stdout{"decision":"approve","modifiedPrompt":"..."} — replace text; {"decision":"block","reason":"..."} — refuse

12.2 Sanitizer script (example)

#!/usr/bin/env bash
set -euo pipefail
 
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt')
 
REDACTED=$(echo "$PROMPT" | sed -E \
  -e 's/(password|passwd|secret|token|api[_-]?key|bearer)[[:space:]=:"'"'"']+[^[:space:]"]+/\1=[REDACTED]/gi' \
  -e 's/AKIA[0-9A-Z]{16}/[AWS_KEY_REDACTED]/g' \
  -e 's/AIza[0-9A-Za-z_-]{35}/[GCP_KEY_REDACTED]/g' \
  -e 's/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/[JWT_REDACTED]/g' \
  -e 's/(jdbc|postgres|postgresql|mysql|mongodb|redis):\/\/[^[:space:]"'"'"']+/\1:\/\/[CONN_REDACTED]/gi')
 
if [ "$PROMPT" != "$REDACTED" ]; then
  jq -n --arg p "$REDACTED" '{decision:"approve", modifiedPrompt:$p}'
fi
exit 0

The list of brand names and client domains — collect with legal/security and update centrally.

12.3 Rollout via the Teams admin console

Hooks are supported in server-managed settings. Extend the JSON from section 4.2:

{
  "env": { "...": "..." },
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "/usr/local/bin/redact-prompt" }
        ]
      }
    ]
  }
}

The script itself is distributed via MDM / a dotfiles repo / a package manager into /usr/local/bin/ or ~/.local/bin/. If the admin console can't push the binary, an onboarding step is required.

12.4 Hook limitations

CaseBehavior
@file mentionsresolved after the hook — file contents are invisible to the hook
Drag-drop images / binaryonly text is in .prompt; binary isn't accessible to the hook
System prompt + tool resultsnot visible, only user input
Slash commandsthe /command args line is visible
Followup messagesfires on every prompt-submit
Speedsynchronous, keep <50ms — otherwise it slows down the UX

12.5 Additional hooks for protection

UserPromptSubmit blocks leaks via text. Files and commands need to be locked down separately:

HookBlocksExample
PreToolUse on Readreading sensitive paths.env, ~/.aws/credentials, ~/.ssh/, /etc/shadow
PreToolUse on Bashcommands with sensitive paths/argscat ~/.aws/*, printenv, env, gcloud auth print-access-token
PreToolUse on Edit / Writewriting secrets into committable files.env not in .gitignore, hardcoded keys

12.6 What the hook does NOT cover

  • Already sent messages in the current session — you can't retro-edit them.
  • File contents read via Read or @mention — the hook only acts on prompt text. Leaks of files with secrets are addressed via PreToolUse:Read.
  • Transcripts in ~/.claude/projects/<dir>/sessions/*.jsonl are stored raw on the laptop — secrets in them remain locally.

12.7 Relationship to telemetry

  • If OTEL_LOG_USER_PROMPTS=0 (the baseline) — the original prompt does not go to the OTel DB. The hook only protects against leaks to Anthropic.
  • If OTEL_LOG_USER_PROMPTS=1 is ever enabled — Claude Code writes the already modified prompt (post-hook) into telemetry. The sanitizer then automatically protects both Anthropic and our DB.

12.8 Documentation