Skip to content

Instantly share code, notes, and snippets.

@kldeb
Last active May 1, 2026 13:29
Show Gist options
  • Select an option

  • Save kldeb/fbdf45169e9f9feca4cccf99a567db22 to your computer and use it in GitHub Desktop.

Select an option

Save kldeb/fbdf45169e9f9feca4cccf99a567db22 to your computer and use it in GitHub Desktop.
Claude Code Local Telemetry: A Self-Hosted Setup

Claude Code Local Telemetry: A Self-Hosted Setup

Capture every Claude Code session locally as queryable telemetry, without running Docker or sending data to a vendor. The result is a small SQL database of your own AI-coding activity that you can mine for automation patterns.

This setup takes about 10 minutes to install and runs as a single background binary plus a few helper scripts.

What you get

  • Events for every tool execution, prompt, API request, skill activation, permission decision, etc.
  • Metrics for token usage, cost, session counts, and active time.
  • Distributed traces that show each prompt expanding into LLM calls and tool executions (beta).
  • A DuckDB query layer with views over the raw OTLP data so you can answer questions like "which bash commands do I run most often" in one line of SQL.

You can paste this entire document into a fresh claude session with the prompt "set this up for me" and Claude will execute the steps. Or follow them by hand.

If you are Claude (or another automated installer) following this guide: there is one step where you must pause and ask the user a question rather than picking a default. It is called out as "Choose a privacy level" in step 6. The user's data sensitivity tolerance is not something you can infer from the guide. Do not enable any OTEL_LOG_* variables until the user has chosen a level explicitly.

Why this stack

The Claude Code monitoring documentation is built on OpenTelemetry, which means anywhere you can ship OTLP, you can ship Claude Code telemetry. Anthropic's official monitoring guide repo uses Docker Compose with Prometheus and Grafana for team-scale dashboards. That is the right choice for organizations.

This document is for the individual developer who wants:

  • The same data, without running Docker
  • Local-only storage (nothing leaves the machine)
  • A simple SQL surface for ad-hoc analysis, not a dashboard server

For comparison, two adjacent local-first tools were considered:

  • otel-tui is a terminal UI that receives OTLP and renders traces, metrics, and logs live. It keeps a rolling 1000-record buffer in memory and has no on-disk persistence, so it is great for live observation but not for pattern analysis over time.
  • otel-desktop-viewer is a browser UI backed by DuckDB. It has no documented CLI query interface, which makes it harder to script.

Neither persists data in a way that is easy to query from the shell, so the setup below uses the OpenTelemetry Collector binary writing JSONL files that DuckDB queries directly. See awesome-opentelemetry for further alternatives.

Architecture

+---------------------+
|  Claude Code (CLI)  |
+----------+----------+
           | OTLP/gRPC :4317
           v
+---------------------+
|   OTel Collector    |   single binary, no Docker
|   otlp receiver --> |
|   file exporter     |
+----------+----------+
           |
           v
+---------------------+
|  ~/.claude/otel/    |
|  data/              |
|   logs.jsonl        |   events: tool_result, user_prompt, ...
|   metrics.jsonl     |   token usage, cost, sessions
|   traces.jsonl      |   span hierarchy per prompt
+----------+----------+
           | read_json()
           v
+---------------------+
|       DuckDB        |   SQL views over JSONL
+---------------------+

The collector writes append-only JSONL files with size-based rotation. DuckDB reads those files in place using read_json(), so there is no separate import step.

Prerequisites

  • macOS (tested on Apple Silicon) or Linux. Paths in this guide assume macOS; on Linux substitute ~/.bashrc for ~/.zshrc and adjust the binary download URL for your architecture.
  • Homebrew (for installing DuckDB).
  • Claude Code CLI v2.x or later.

Setup

1. Install DuckDB

brew install duckdb

For Linux or Windows install instructions see duckdb.org.

2. Download the OpenTelemetry Collector binary

We need the contrib distribution because it includes the file exporter. There is no official Homebrew formula, so download from the GitHub release page:

mkdir -p ~/.claude/otel/{bin,data,queries,logs}

ARCH=$(uname -m | sed 's/x86_64/amd64/')
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
VERSION=v0.151.0   # check release page for latest

curl -L -o /tmp/otelcol-contrib.tar.gz \
  "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/${VERSION}/otelcol-contrib_${VERSION#v}_${OS}_${ARCH}.tar.gz"

tar xzf /tmp/otelcol-contrib.tar.gz -C ~/.claude/otel/bin/ otelcol-contrib
chmod +x ~/.claude/otel/bin/otelcol-contrib
rm /tmp/otelcol-contrib.tar.gz

~/.claude/otel/bin/otelcol-contrib --version

3. Write the collector config

The config receives OTLP on localhost:4317 (gRPC) and :4318 (HTTP), then writes each signal type to its own JSONL file with rotation.

Save this as ~/.claude/otel/config.yaml:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: localhost:4317
      http:
        endpoint: localhost:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 100

exporters:
  file/traces:
    path: ${env:HOME}/.claude/otel/data/traces.jsonl
    rotation:
      max_megabytes: 250
      max_backups: 30
      max_days: 90
    flush_interval: 5s
  file/metrics:
    path: ${env:HOME}/.claude/otel/data/metrics.jsonl
    rotation:
      max_megabytes: 250
      max_backups: 30
      max_days: 90
    flush_interval: 5s
  file/logs:
    path: ${env:HOME}/.claude/otel/data/logs.jsonl
    rotation:
      max_megabytes: 250
      max_backups: 30
      max_days: 90
    flush_interval: 5s

service:
  telemetry:
    logs:
      level: warn
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [file/traces]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [file/metrics]
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [file/logs]

Rotation caps each signal at roughly 7.5 GB (250 MB times 30 backups) and drops anything older than 90 days. Tune to taste.

4. Write the helper scripts

Four small scripts go in ~/.claude/otel/bin/. Make them all executable (chmod +x ~/.claude/otel/bin/*.sh).

start.sh starts the collector in the background and writes its PID:

#!/usr/bin/env bash
set -euo pipefail
OTEL_DIR="$HOME/.claude/otel"
PID_FILE="$OTEL_DIR/collector.pid"
LOG_FILE="$OTEL_DIR/logs/collector.log"

if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
  echo "Collector already running (pid $(cat "$PID_FILE"))"
  exit 0
fi

nohup "$OTEL_DIR/bin/otelcol-contrib" \
  --config "$OTEL_DIR/config.yaml" \
  >> "$LOG_FILE" 2>&1 &

echo $! > "$PID_FILE"
sleep 1

if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
  echo "Collector started (pid $(cat "$PID_FILE"))"
  echo "Listening: gRPC :4317, HTTP :4318"
  echo "Data: $OTEL_DIR/data/"
  echo "Logs: $LOG_FILE"
else
  echo "Collector failed to start. Last log lines:"
  tail -20 "$LOG_FILE"
  rm -f "$PID_FILE"
  exit 1
fi

stop.sh:

#!/usr/bin/env bash
set -euo pipefail
PID_FILE="$HOME/.claude/otel/collector.pid"

if [[ ! -f "$PID_FILE" ]]; then
  echo "Collector not running (no pid file)"
  exit 0
fi

PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
  kill "$PID"
  echo "Stopped collector (pid $PID)"
else
  echo "Collector not running (stale pid file)"
fi
rm -f "$PID_FILE"

status.sh:

#!/usr/bin/env bash
set -euo pipefail
OTEL_DIR="$HOME/.claude/otel"
PID_FILE="$OTEL_DIR/collector.pid"

if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
  echo "Collector running (pid $(cat "$PID_FILE"))"
else
  echo "Collector not running"
fi

echo
echo "Data files:"
if [[ -d "$OTEL_DIR/data" ]] && compgen -G "$OTEL_DIR/data/*.jsonl*" > /dev/null; then
  ls -lh "$OTEL_DIR/data/"
else
  echo "  (none yet)"
fi

query.sh opens DuckDB with the views pre-loaded:

#!/usr/bin/env bash
exec duckdb -init "$HOME/.claude/otel/queries/init.sql" "$@"

5. Write the DuckDB init file

This file is loaded automatically by query.sh. It defines views that flatten the OTLP JSON envelope into a tabular shape and promote the most common Claude Code event attributes to columns.

The views use a columns hint plus JSON path extraction so they work even when a signal file is empty (no stub records required).

Save as ~/.claude/otel/queries/init.sql:

-- Helper: extract a string-coerced attribute by key from an OTLP attribute
-- list. Iterates the array via json_each so the macro doesn't depend on
-- inferred struct shapes.
CREATE OR REPLACE MACRO get_attr(attrs_json, name) AS (
  (SELECT
    COALESCE(
      json_extract_string(je.value, '$.value.stringValue'),
      json_extract_string(je.value, '$.value.intValue'),
      json_extract_string(je.value, '$.value.doubleValue'),
      json_extract_string(je.value, '$.value.boolValue')
    )
   FROM json_each(attrs_json) je
   WHERE json_extract_string(je.value, '$.key') = name
   LIMIT 1)
);

-- Raw log records flattened from the OTLP JSON envelope.
-- The `columns` hint keeps the top-level shape stable even with empty files.
CREATE OR REPLACE VIEW log_records AS
WITH files AS (
  SELECT unnest(resourceLogs) AS rl
  FROM read_json(
    getenv('HOME') || '/.claude/otel/data/logs*.jsonl',
    columns = {'resourceLogs': 'JSON[]'},
    format = 'newline_delimited',
    ignore_errors = true
  )
), scopes AS (
  SELECT
    json_extract(rl, '$.resource.attributes') AS resource_attrs,
    sl_je.value AS sl
  FROM files, json_each(json_extract(rl, '$.scopeLogs')) sl_je
), records AS (
  SELECT
    resource_attrs,
    lr_je.value AS lr
  FROM scopes, json_each(json_extract(sl, '$.logRecords')) lr_je
)
SELECT
  to_timestamp(CAST(json_extract_string(lr, '$.timeUnixNano') AS BIGINT) / 1e9) AS event_time,
  resource_attrs,
  json_extract(lr, '$.attributes') AS attrs,
  json_extract(lr, '$.body')       AS body
FROM records;

-- Convenience view: common Claude Code event attributes promoted to columns.
CREATE OR REPLACE VIEW events AS
SELECT
  event_time,
  get_attr(attrs, 'event.name')                              AS event_name,
  get_attr(attrs, 'session.id')                              AS session_id,
  get_attr(attrs, 'prompt.id')                               AS prompt_id,
  get_attr(attrs, 'tool_name')                               AS tool_name,
  get_attr(attrs, 'success')                                 AS success,
  TRY_CAST(get_attr(attrs, 'duration_ms')   AS BIGINT)       AS duration_ms,
  TRY_CAST(get_attr(attrs, 'input_tokens')  AS BIGINT)       AS input_tokens,
  TRY_CAST(get_attr(attrs, 'output_tokens') AS BIGINT)       AS output_tokens,
  TRY_CAST(get_attr(attrs, 'cache_read_tokens')    AS BIGINT) AS cache_read_tokens,
  TRY_CAST(get_attr(attrs, 'cache_creation_tokens') AS BIGINT) AS cache_creation_tokens,
  TRY_CAST(get_attr(attrs, 'cost_usd')      AS DOUBLE)       AS cost_usd,
  get_attr(attrs, 'model')                                   AS model,
  get_attr(attrs, 'query_source')                            AS query_source,
  get_attr(attrs, 'skill.name')                              AS skill_name,
  get_attr(attrs, 'tool_parameters')                         AS tool_parameters,
  get_attr(attrs, 'command_name')                            AS command_name,
  get_attr(resource_attrs, 'host.name')                      AS host_name,
  attrs,
  resource_attrs
FROM log_records;

-- Quick subset views per event type for common analyses.
CREATE OR REPLACE VIEW tool_results    AS SELECT * FROM events WHERE event_name = 'tool_result';
CREATE OR REPLACE VIEW user_prompts    AS SELECT * FROM events WHERE event_name = 'user_prompt';
CREATE OR REPLACE VIEW api_requests    AS SELECT * FROM events WHERE event_name = 'api_request';
CREATE OR REPLACE VIEW api_errors      AS SELECT * FROM events WHERE event_name = 'api_error';
CREATE OR REPLACE VIEW skill_activated AS SELECT * FROM events WHERE event_name = 'skill_activated';
CREATE OR REPLACE VIEW tool_decisions  AS SELECT * FROM events WHERE event_name = 'tool_decision';
CREATE OR REPLACE VIEW compactions     AS SELECT * FROM events WHERE event_name = 'compaction';

.print
.print Views: events, tool_results, user_prompts, api_requests, api_errors,
.print        skill_activated, tool_decisions, compactions, log_records
.print Macros: get_attr(attrs, 'key.name')
.print

6. Choose a privacy level, then add env vars and aliases to your shell

Claude Code telemetry exposes increasing detail through a series of opt-in environment variables. Each one captures more data and creates more risk if the JSONL files leak. Pick the level that matches your environment.

Claude / automated installers: stop and ask the user which level they want before continuing. Do not pick a default. Show them the table below, let them choose, then proceed.

Level Adds these env vars Captures Reasonable when
Minimal (none extra) Counts, durations, model names, token counts, cost. No content. Privacy-sensitive workplace; you only care about cost and usage trends.
Standard OTEL_LOG_TOOL_DETAILS=1 Above + bash command bodies, skill names, file paths, MCP server names, command names Pattern mining for automation. This is the most useful level for most people.
Verbose OTEL_LOG_TOOL_DETAILS=1 + OTEL_LOG_USER_PROMPTS=1 Above + verbatim text of every prompt you type You want to correlate prompts with the tool sequences they triggered.
Full All of the above + OTEL_LOG_TOOL_CONTENT=1 Above + tool input/output bodies in trace spans (Read results, full Bash output, etc., truncated at 60 KB) Forensic-grade. Treat ~/.claude/otel/data/ as a secrets file.

There is one further variable, OTEL_LOG_RAW_API_BODIES, that captures the entire Anthropic Messages API request and response bodies. It includes the full conversation history on every turn. Most users should not enable it; if you do, ship the directory through a log collector rather than letting it sit on disk.

Once the level is chosen, append to ~/.zshrc (or ~/.bashrc on Linux), including only the OTEL_LOG_* lines for the chosen level:

# Claude Code telemetry
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1   # spans/traces (beta)
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_TRACES_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_METRIC_EXPORT_INTERVAL=10000

# Privacy level (uncomment based on your choice above)
# export OTEL_LOG_TOOL_DETAILS=1               # Standard, Verbose, Full
# export OTEL_LOG_USER_PROMPTS=1               # Verbose, Full
# export OTEL_LOG_TOOL_CONTENT=1               # Full only

alias otel='~/.claude/otel/bin/start.sh'
alias otel-stop='~/.claude/otel/bin/stop.sh'
alias otel-status='~/.claude/otel/bin/status.sh'
alias otel-query='~/.claude/otel/bin/query.sh'

Reload:

source ~/.zshrc

7. Smoke-test the pipeline

otel              # start the collector
otel-status       # confirm it's running

# Send a synthetic OTLP log to confirm end-to-end flow:
curl -sS -X POST http://localhost:4318/v1/logs \
  -H 'Content-Type: application/json' \
  -d '{
    "resourceLogs": [{
      "resource": {"attributes": [{"key": "service.name", "value": {"stringValue": "claude-code"}}]},
      "scopeLogs": [{"logRecords": [{
        "timeUnixNano": "'"$(date +%s)000000000"'",
        "body": {"stringValue": "smoke test"},
        "attributes": [
          {"key": "event.name", "value": {"stringValue": "tool_result"}},
          {"key": "tool_name", "value": {"stringValue": "TestTool"}},
          {"key": "session.id", "value": {"stringValue": "smoketest"}}
        ]
      }]}]
    }]
  }'

sleep 7
otel-query -c "SELECT event_time, event_name, tool_name, session_id FROM events;"

If you see your test event in the output, the pipeline works. The collector keeps running in the background and will receive data from any new shell that sources the env vars above.

Daily use

otel              # start (idempotent)
otel-status       # is it running, what files exist
otel-query        # open interactive DuckDB shell with views loaded
otel-query -c "SELECT ..."   # one-shot query
otel-stop         # pause collection

The env vars in .zshrc mean any new shell launching claude will export telemetry to localhost:4317 automatically. Already-running shells need source ~/.zshrc (or a new terminal window) to pick up the change.

To temporarily disable in a specific shell, set CLAUDE_CODE_ENABLE_TELEMETRY=0 in that shell.

Example queries

These run against the views defined in init.sql. Each maps to a real question worth answering.

Top tools by use over the last 24 hours:

SELECT tool_name, COUNT(*) AS uses, ROUND(AVG(duration_ms)) AS avg_ms
FROM tool_results
WHERE event_time > now() - INTERVAL 1 DAY
GROUP BY 1
ORDER BY 2 DESC;

Cost and cache hit rate per session:

SELECT
  substr(session_id, 1, 8) AS session,
  COUNT(*) AS llm_calls,
  SUM(input_tokens)  AS input_tokens,
  SUM(output_tokens) AS output_tokens,
  SUM(cache_read_tokens) AS cache_read,
  ROUND(100.0 * SUM(cache_read_tokens)
        / NULLIF(SUM(input_tokens + cache_read_tokens), 0), 1) AS cache_hit_pct,
  ROUND(SUM(cost_usd), 4) AS cost_usd
FROM api_requests
GROUP BY 1
ORDER BY cost_usd DESC NULLS LAST;

Most-used skills:

SELECT skill_name, COUNT(*) AS activations
FROM skill_activated
WHERE skill_name IS NOT NULL
GROUP BY 1
ORDER BY 2 DESC;

Bash commands you run repeatedly (automation candidates):

SELECT
  json_extract_string(tool_parameters, '$.bash_command') AS cmd,
  COUNT(*) AS uses,
  SUM(duration_ms) AS total_ms
FROM tool_results
WHERE tool_name = 'Bash'
GROUP BY 1
ORDER BY 2 DESC
LIMIT 30;

Permission decisions you keep accepting (allowlist candidates):

SELECT tool_name,
       get_attr(attrs, 'decision_source') AS source,
       COUNT(*) AS n
FROM tool_decisions
WHERE success IS NOT NULL
GROUP BY 1, 2
ORDER BY 3 DESC;

Span hierarchy for traces (requires CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1):

WITH t AS (
  SELECT unnest(resourceSpans) AS rs
  FROM read_json(getenv('HOME') || '/.claude/otel/data/traces*.jsonl',
                 format='newline_delimited', union_by_name=true, ignore_errors=true)
), spans AS (
  SELECT unnest(ss.spans) AS sp
  FROM (SELECT unnest(rs.scopeSpans) AS ss FROM t)
)
SELECT
  list_filter(sp.attributes, x -> x.key = 'session.id')[1].value.stringValue AS session_id,
  sp.name AS span_name,
  COUNT(*) AS n,
  ROUND(AVG(CAST(list_filter(sp.attributes, x -> x.key = 'duration_ms')[1].value.intValue AS BIGINT))) AS avg_ms
FROM spans
GROUP BY 1, 2
ORDER BY 1, 2;

Security and privacy

This setup writes only to your local disk. Nothing is sent to a vendor. The Claude Code monitoring docs document the full data model; what follows is the practical view.

What each level captures

See the privacy-level table in step 6 above. The default scripts in this guide write nothing to your shell init until you choose a level.

Potential issues to be aware of

  • JSONL files are plaintext and unencrypted. Anyone with read access to ~/.claude/otel/data/ can read everything captured. macOS file permissions protect against other users on the same machine, but not against malware running as your user, backup tools, or cloud-sync services that index your home directory.
  • Prompts often contain sensitive context even when you do not think of them as secrets: pasted code with environment variable names, error messages with stack traces, internal hostnames, presigned URLs, occasional credentials. Enabling OTEL_LOG_USER_PROMPTS=1 captures all of them verbatim. Audit your habits before enabling Verbose or Full.
  • Bash command bodies leak credentials by accident. With OTEL_LOG_TOOL_DETAILS=1, every command Claude Code runs is recorded. If Claude ever runs aws s3 cp with a presigned URL, psql -h ... -U ... with a connection string, or curl -H "Authorization: Bearer ...", those arguments are persisted.
  • Tool content can include large payloads. With OTEL_LOG_TOOL_CONTENT=1, span events include tool input and output bodies up to 60 KB each. Reading a file containing secrets means those secrets land in the trace data.
  • Default retention is 90 days. Adjust max_days in config.yaml if you want a shorter window. Files rotate by size at 250 MB; rotated backups age out independently.
  • Do not place ~/.claude/otel/data/ inside a tracked repository. It is outside the home dir's typical .gitignore patterns. The default location in this guide (~/.claude/otel/data/) is safe as long as your home is not itself a git repo.
  • The collector log itself can leak metadata. ~/.claude/otel/logs/collector.log records OTLP errors and connection events. It is much less sensitive than the data files but worth knowing about if you share your machine.

Off switch

Telemetry only flows while the collector is running. otel-stop is a clean hard stop with no side effects: existing JSONL data stays on disk, no new data is captured, and any active Claude Code session continues working without telemetry until the collector comes back.

To wipe captured data: rm ~/.claude/otel/data/*.jsonl*.

To disable at the source even with the collector running, set CLAUDE_CODE_ENABLE_TELEMETRY=0 in the shell that launches claude.

Multi-session and concurrent use

The collector handles concurrent sessions transparently. Every Claude Code instance pushes to localhost:4317; events from different sessions interleave in the JSONL files but each carries its own session.id attribute, so they are trivially separable in queries.

If you regularly run 3+ concurrent sessions, raise the max_megabytes in the collector's rotation config proportionally. The architecture itself does not change.

Migrating to a cloud backend (small teams)

The same env-var configuration that drives the local collector can ship telemetry to any OTLP-compatible cloud backend. For small teams (~10 developers) the free tiers of mainstream providers comfortably handle the data volume that AI-assisted coding produces, which is small compared to typical application telemetry (single-digit MB/day per developer).

Top 3 recommendations for small teams

Provider Free tier Strengths Caveats
Grafana Cloud 50 GB logs, 10K metrics series, 50 GB traces, 14-day retention OTLP-native, generous free tier, LogQL + PromQL + SQL Expressions, familiar Grafana UI, clean upgrade path Three query languages to learn
Honeycomb 20M events/month Best-in-class trace exploration; BubbleUp surfaces outliers automatically; the prompt → tools span hierarchy from Claude Code renders beautifully Trace-first; logs and metrics are second-class
Axiom 500 GB/month Single unified platform for logs/traces/metrics, OTel-native, APL (Kusto-like) queries APL is yet another query language

For most teams starting from this guide, Grafana Cloud's free tier is the path of least resistance: a 10-person team's Claude Code telemetry fits well inside the limits, the OTLP endpoint is a one-line change, and SQL Expressions are close enough to the DuckDB queries you've written locally that your query library translates with minimal rewrite.

Skip for this scale

  • Datadog ($15-31/host/month minimum, built for 100+ engineering orgs)
  • New Relic (free tier is capped at one user; gets pricey past that)
  • Self-hosted SigNoz/ClickHouse (real engineering ownership cost; revisit only if you outgrow a SaaS free tier)

Migration mechanics: same setup, different endpoint

Whichever provider you pick, the change is the OTLP endpoint plus an authentication header. Example for Grafana Cloud:

# Replace the local-collector endpoint with the cloud ingest URL
export OTEL_EXPORTER_OTLP_ENDPOINT="https://otlp-gateway-prod-us-east-0.grafana.net/otlp"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <base64-encoded user:token>"
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

# Tag every signal with team identification so dashboards can segment by team/user
export OTEL_RESOURCE_ATTRIBUTES="team.id=engineering,cost_center=eng-123,user.name=$USER"

Honeycomb uses a header-based API key (x-honeycomb-team); Axiom uses a bearer token plus an x-axiom-dataset header. Each provider's docs have a copy-pasteable snippet.

Run local + cloud simultaneously

You can keep the personal local setup and feed a team backend at the same time by adding a second OTLP exporter to config.yaml:

exporters:
  file/logs: { ... }    # existing
  otlp/cloud:
    endpoint: otlp-gateway-prod-us-east-0.grafana.net:443
    headers:
      Authorization: "Basic <base64-encoded user:token>"

service:
  pipelines:
    logs:
      exporters: [file/logs, otlp/cloud]   # fan out to both

This pattern lets each developer keep their personal DuckDB-queryable history while the team gets aggregate visibility.

Organization-managed deployment

For deployments where developers should not have to configure their own shells, set these env vars centrally through the Claude Code managed settings file and distribute via MDM. Managed settings have higher precedence than user shell config, so the team backend is enforced.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment