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 ──► Grafana1.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.emailcomes 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)
| Attribute | Description | Control |
|---|---|---|
service.name | "claude-code" | always |
service.version / app.version | CLI version | OTEL_METRICS_INCLUDE_VERSION |
os.type, os.version, host.arch | OS + CPU | always |
wsl.version | only on WSL | auto |
session.id | session UUID | OTEL_METRICS_INCLUDE_SESSION_ID (default on) |
organization.id | Teams organization ID | on auth |
user.account_uuid, user.account_id | Anthropic account UUID | OTEL_METRICS_INCLUDE_ACCOUNT_UUID (default on) |
user.id | anonymous device ID | always |
user.email | OAuth email | on OAuth login |
terminal.type | iTerm / vscode / cursor / tmux | auto |
prompt.id | UUID, ties events of one prompt | events only |
workspace.host_paths | paths from desktop app | events only |
You can attach custom labels (team, environment, cost_center, etc.) via OTEL_RESOURCE_ATTRIBUTES.
2.2 Metrics (8)
| Metric | Type | Unit | Trigger | Attributes |
|---|---|---|---|---|
claude_code.session.count | counter | count | session start | start_type: fresh/resume/continue |
claude_code.lines_of_code.count | counter | count | code edit | type: added/removed |
claude_code.pull_request.count | counter | count | PR created | — |
claude_code.commit.count | counter | count | git commit | — |
claude_code.cost.usage | counter | USD | each API request | model, query_source, speed, effort |
claude_code.token.usage | counter | tokens | each API request | type, model, query_source, speed, effort |
claude_code.code_edit_tool.decision | counter | count | accept/reject edit | tool_name, decision, source, language |
claude_code.active_time.total | counter | sec | user/CLI activity | type: 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.
| Event | Trigger | Key attributes |
|---|---|---|
claude_code.user_prompt | user submitted a prompt | prompt_length, command_name; prompt only with OTEL_LOG_USER_PROMPTS=1 |
claude_code.tool_result | tool finished | tool_name, tool_use_id, success, duration_ms, error_type, I/O sizes |
claude_code.api_request | successful API call | model, cost_usd, duration_ms, tokens, request_id, speed, effort |
claude_code.api_error | API fail | model, error, status_code, duration_ms, attempt |
claude_code.api_retries_exhausted | final fail after retries | total_attempts, total_retry_duration_ms |
claude_code.api_request_body | only with OTEL_LOG_RAW_API_BODIES | full conversation with the model |
claude_code.api_response_body | only with OTEL_LOG_RAW_API_BODIES | model response |
claude_code.tool_decision | accept/reject permission | tool_name, decision, source |
claude_code.permission_mode_changed | mode change | from_mode, to_mode, trigger |
claude_code.auth | /login or /logout | action, success, auth_method |
claude_code.mcp_server_connection | MCP connect/disconnect/fail | status, transport_type, duration_ms |
claude_code.internal_error | internal exception | error_name, error_code (no message/stack) |
claude_code.plugin_installed | plugin installed | marketplace.is_official, install.trigger |
claude_code.skill_activated | skill invoked | skill.name, invocation_trigger |
claude_code.at_mention | resolved @mention | mention_type, success |
claude_code.hook_execution_start | hooks started | hook_event, hook_name, num_hooks |
claude_code.hook_execution_complete | hooks finished | + num_success, num_blocking, total_duration_ms |
claude_code.compaction | context compaction | trigger, 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
- Full reference: https://code.claude.com/docs/en/monitoring-usage.md
- Server-managed settings: https://code.claude.com/docs/en/server-managed-settings.md
- Admin setup: https://code.claude.com/docs/en/admin-setup.md
- Settings reference: https://code.claude.com/docs/en/settings.md
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
| Data | Default | Enabled by |
|---|---|---|
| Prompt text | length only | OTEL_LOG_USER_PROMPTS=1 |
| Tool args (Bash commands, URLs, patterns) | — | OTEL_LOG_TOOL_DETAILS=1 (~4 KB cap) |
| File paths | in tool events | OTEL_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 names | collapsed | OTEL_LOG_TOOL_DETAILS=1 |
| Extended-thinking | always redacted | can't be enabled |
User consent
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:
- Before rollout, notify the team: what we collect, where it's stored, who has access.
- Document it in the onboarding doc.
- If
OTEL_LOG_TOOL_DETAILSorOTEL_LOG_RAW_API_BODIESis being enabled — a separate explicit announcement, since passwords in Bash args, tokens in URLs, and file contents can leak.
Recommended baseline
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/metricsand/v1/logssuffixes toOTEL_EXPORTER_OTLP_ENDPOINTitself — the endpoint should point at the/otelprefix only.
4.3 Behavior
- Lands at
~/.claude/remote-settings.jsonfor each user. - Sync: on login + once an hour.
- Local edits to
remote-settings.jsonare wiped on the next sync. envfrom 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:
- Verify the
Authorization: Bearer <token>header against the expected one. Mismatch —401. - INSERT body as-is into
claude_code_otlp_raw(kind =metricsorlogs). - Reply
200 {}. No parsing on the hot path.
Parser — periodic job (cron / scheduler) every minute:
- SELECT N raw rows where
parsed_at IS NULL(batch 500–2000). - For each:
- JSON.parse → walk over
resourceMetrics/resourceLogs. - For metrics: pull name, value, timestamp from
dataPoints+ resource and point attributes. INSERT intoclaude_code_metric_points. - For logs: pull
event.name, timestamp, attributes. INSERT intoclaude_code_events(the rest goes into the JSONattrscolumn).
- JSON.parse → walk over
- 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
- Generate a token:
openssl rand -hex 32→ store in a secret manager. - Apply a migration (Flyway / Liquibase / Alembic / sqlx-migrate) with the three tables.
- Add the ingestion endpoint + parser to the app code.
- Wire the token into the app config via an env var / secret manager. Never commit it.
- Deploy through CI.
- 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 {} - Enable the env block in the Teams admin console (section 4.2). Within an hour, metrics will start arriving.
- Verify after an hour:
SELECT COUNT(*), MIN(received_at), MAX(received_at) FROM claude_code_otlp_raw;.
6. Security
| Threat | Mitigation |
|---|---|
| Third-party push to the endpoint | Bearer token in Authorization, checked in the endpoint |
| Traffic interception | TLS on the domain (or a managed proxy) |
| Token leak | One shared token, rotation through admin console + reissue in the secret store |
| DB data access | Existing access controls (read-only role for analytics) |
| PII leaks in metrics | Don't enable OTEL_LOG_* flags without an explicit team announcement |
| Bash args with secrets | If OTEL_LOG_TOOL_DETAILS is on — filter in the parser (regex-mask tokens / passwords before storing) |
| DoS on the endpoint | Rate-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
INSERTRPS intoclaude_code_otlp_rawover 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.
| Alert | SQL condition | Severity |
|---|---|---|
| Daily cost spike | today's cost > 2× the 7-day average | warning |
| Per-user cost cap | any user > $100/day | warning |
| API error rate | errors > 5% of requests over 15 min | critical |
| Telemetry silence | 0 INSERTs into otlp_raw for 1 hour during business hours | warning |
| Parser stuck | unprocessed rows (parsed_at IS NULL) > 10000 | warning |
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
| Method | Enforcement | Effort | Per-user tokens | Recommendation |
|---|---|---|---|---|
Teams admin console (remote-settings.json) | strong | minimal | no | first choice |
MDM (managed-settings.json) | maximal | high | possible via templates | if you have MDM |
Bootstrap script + ~/.claude/settings.json | weak | low | yes | fallback |
9. Extensions (when needed)
9.1 Grafana on top of the DB
When you want pretty dashboards:
- Stand up Grafana (a separate container or managed).
- Add a datasource (MySQL / Postgres) → connect to the existing DB with a read-only role.
- 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 + GrafanaYour 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_tokenstable (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
| Stage | Steps | Estimate |
|---|---|---|
| MVP | DB migration + ingestion endpoint + parser in the existing backend, env block in admin console | 0.5 day |
| Reports | basic SQL from section 7, Metabase/Superset/Grafana on top of the DB | 0.5–1 day |
| Privacy agreement | team announcement, wiki update, description of the data set | 0.5 day |
| Alerts | scheduled 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 breakdown | 1–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
curlon/otel/v1/metricsreturns 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.emailinotlp_raw - Parser is keeping up:
parsed_at IS NULLcount 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.
| Channel | Description |
|---|---|
| stdin | JSON: prompt, cwd, session_id, transcript_path |
| Exit 0 | the prompt continues as-is |
| Exit 2 | the 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 0The 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
| Case | Behavior |
|---|---|
@file mentions | resolved after the hook — file contents are invisible to the hook |
| Drag-drop images / binary | only text is in .prompt; binary isn't accessible to the hook |
| System prompt + tool results | not visible, only user input |
| Slash commands | the /command args line is visible |
| Followup messages | fires on every prompt-submit |
| Speed | synchronous, 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:
| Hook | Blocks | Example |
|---|---|---|
PreToolUse on Read | reading sensitive paths | .env, ~/.aws/credentials, ~/.ssh/, /etc/shadow |
PreToolUse on Bash | commands with sensitive paths/args | cat ~/.aws/*, printenv, env, gcloud auth print-access-token |
PreToolUse on Edit / Write | writing 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
Reador@mention— the hook only acts on prompt text. Leaks of files with secrets are addressed viaPreToolUse:Read. - Transcripts in
~/.claude/projects/<dir>/sessions/*.jsonlare 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=1is 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
- Hooks reference: https://code.claude.com/docs/en/hooks
- Server-managed hooks: https://code.claude.com/docs/en/server-managed-settings.md
