Postgres and ClickHouse stores
Keep sessions in Postgres or ClickHouse for coding.sh serve: setup, the collector config, and queries you can run on the tables.
Pick a store
| Store | CODING_SH_STORE_URL | Blobs | Pick it when |
|---|---|---|---|
| SQLite | sqlite:///data/index.db (the default) | file:///data/blobs | one machine, one team |
| Postgres | postgres://… | the same database, or S3 | real traffic, several viewer replicas, the CLI on the team database |
| ClickHouse | clickhouse://… or clickhouses://… | file:// or S3, never ClickHouse | many sessions, or analytical SQL over the tables |
Each store other than SQLite is a plugin package, loaded by its name (coding.sh-store-<scheme>). There is nothing to register. The Docker image ghcr.io/jackulau/coding.sh includes all three plugins. Outside Docker, install the one you need next to coding.sh:
| Package | URL scheme |
|---|---|
coding.sh-store-postgres | postgres://, postgresql:// |
coding.sh-store-clickhouse | clickhouse://, clickhouses:// |
coding.sh-store-s3 | s3:// (blobs only) |
The viewer, CLI, MCP tools, datasets and experiments all work on the same store URL. Tables are created and migrated on first start. One serve process writes at a time; the others wait to take over.
Postgres
npm i -g coding.sh coding.sh-store-postgres
export CODING_SH_STORE_URL='postgres://coding:secret@db.internal:5432/coding?sslmode=require'
coding.sh serve With Compose, deploy/compose/compose.postgres.yml runs serve and Postgres 17 together. Postgres is reachable only from serve, on the stack's own network. Set POSTGRES_PASSWORD in .env before the first start; Postgres keeps the password it started with.
| URL parameter | Default | Meaning |
|---|---|---|
schema | public | Created if missing. Separate deployments can share a database with separate schemas. |
pool_max | 10 | Connections in the pool. The writer lock uses one more. |
| anything else | Passed to the pg driver as is: sslmode, host=/socket/dir, and so on. |
- Blobs are stored in the same database, as zstd-compressed rows. Set
CODING_SH_BLOB_URLto ans3://URL to keep them in object storage instead. - The writer lock is a session advisory lock. serve needs a direct connection or a session-mode pooler; a transaction-mode pooler drops it.
- Search uses a GIN-indexed
tsvectorand needs no extensions.--wherefilters run in the database. - Statements time out after 30 s.
People with database access can run the CLI against the team database. With --store, local spool files are not pushed into it:
coding.sh sessions --store 'postgres://coding:…@db-host:5432/coding' --project alice The Postgres password grants full access to the data, so most of the team should use the viewer.
The tables
events, sessions, cursors, tasks, bundles, runs, labels, kv, datasets, dataset_latest, experiments, judges and blobs, plus coding_sh_migrations. events and sessions carry project_id (default when unset). JSON fields are text holding JSON, so stats::jsonb->>'events' works in your own SQL. Retries and replays never write an event twice.
ClickHouse
There are two ways to use ClickHouse, and they can run together:
| Store plugin | Collector route | |
|---|---|---|
| What goes to ClickHouse | Everything coding.sh stores: sessions, events, search text, eval assets | Span metadata only, in the collector's otel_traces table |
| Who reads it | coding.sh (viewer, CLI, MCP) and your own SQL | Your own SQL or Grafana; coding.sh collector import can pull agent traces back |
| What runs | coding.sh serve with a clickhouse:// store URL | Your OpenTelemetry Collector with the clickhouse exporter |
| Pick it when | ClickHouse is the database you want coding.sh to run on | You already collect traces into ClickHouse |
Run the server on ClickHouse
docker run -d --name coding-sh -p 4318:4318 -v coding-sh:/data --restart unless-stopped \
-e CODING_SH_STORE_URL='clickhouses://coding:pass@ch.example.com:8443/coding' \
-e CODING_SH_BLOB_URL='file:///data/blobs' \
ghcr.io/jackulau/coding.sh With Compose: deploy/compose/compose.clickhouse.yml runs serve and ClickHouse together, with blobs on the coding-sh-data volume. Set CLICKHOUSE_PASSWORD in .env before the first start.
| URL part | Default | Meaning |
|---|---|---|
| scheme | clickhouse is HTTP, clickhouses is HTTPS. ?secure=1 also turns on HTTPS. | |
| port | 8123, or 8443 with TLS | The HTTP interface, not the native port 9000. |
| user, password | default, empty | Errors show the password as ***. |
| path | default | The database. Created if missing. |
| anything else | Passed to ClickHouse as a setting, such as select_sequential_consistency=1. |
- Blobs never go to ClickHouse. Keep them on
file://or on S3. - Each table is a
ReplacingMergeTree, and every read usesFINAL, so the newest row wins.coding.sh gc --compactrunsOPTIMIZE TABLE … FINAL. - serve takes a 30-second lease in the
lockstable, so one replica writes. If the writer dies, another takes over within 30 s. - Queries time out after 30 s.
What ClickHouse does not support
- Transactions. Ingest writes events before it moves its cursors, so a crash replays rather than loses. Re-parsing a session inserts the new rows before it deletes the old ones, so a reader in between can see both.
- More than one writer. Two processes writing at once can over-count session stats. The lease prevents this for serve.
- Replicated DDL. Tables use plain MergeTree engines. On a self-managed cluster, create the database with the
Replicatedengine before the first start. ClickHouse Cloud converts the tables to SharedMergeTree; add?select_sequential_consistency=1for read-after-write across replicas.
Export to ClickHouse through an OpenTelemetry Collector
If your traces already go through the Collector's clickhouse exporter, send coding.sh sessions there too. coding.sh keeps its own store, and an otlp sink copies each session's spans to the collector, which writes them to otel.otel_traces. You query that table; coding.sh does not read it.
deploy/clickhouse/ has a working pair, otel-collector.yaml and docker-compose.yml, with pinned images and ports on 127.0.0.1:
export CLICKHOUSE_PASSWORD=$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')
docker compose -f deploy/clickhouse/docker-compose.yml up -d --wait The collector part of the config, to merge into your own:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
clickhouse:
endpoint: tcp://clickhouse:9000?dial_timeout=10s
database: otel
username: ${env:CLICKHOUSE_USER}
password: ${env:CLICKHOUSE_PASSWORD}
ttl: 720h
create_schema: true
# json: true # attributes as the JSON type; needs enable_json_type=1 on the server
sending_queue:
batch:
flush_timeout: 5s
service:
pipelines:
traces:
receivers: [otlp]
exporters: [clickhouse] Then add a sink on each machine that captures sessions. The Compose file publishes the collector on 4319, so it can share a host with serve on 4318:
[[sinks]]
kind = "otlp"
name = "clickhouse"
endpoint = "http://collector:4319"
mode = "metadata" On a team, set CODING_SH_FORWARD_URL=http://collector:4319 on serve instead, and it forwards every trace it commits.
The span table
What lands in otel_traces, with ServiceName = 'coding.sh':
SpanName | One per | Attributes (SpanAttributes['…']) |
|---|---|---|
invoke_agent <agent> | session (an open one can have several rows) | gen_ai.operation.name, gen_ai.agent.name, gen_ai.request.model, gen_ai.conversation.id (the session id), gen_ai.usage.*, coding_sh.cost_usd, coding_sh.stats.* (events, turns, tool_calls, tool_errors, files_changed, tests_passed, tests_failed), coding_sh.session.flags, vcs.ref.head.revision |
execute_tool <tool> | tool call | gen_ai.operation.name, gen_ai.tool.name, gen_ai.tool.call.id, gen_ai.agent.name; StatusCode is Error when the call failed |
chat <model> | model call | gen_ai.operation.name, gen_ai.request.model, gen_ai.provider.name, gen_ai.usage.*, coding_sh.cost_usd |
gen_ai.usage.* is input_tokens, output_tokens, cache_read.input_tokens and cache_write.input_tokens. input_tokens is the whole prompt, cache included; the uncached part is coding_sh.usage.uncached_input_tokens. A call with reasoning tokens also gets reasoning.output_tokens. A session's spans share one TraceId, subagents join their parent's trace, and Duration is in nanoseconds.
- Map columns store every attribute as a string: cast with
toFloat64OrZeroandtoUInt64OrZero.coding_sh.session.flagsis a JSON array string. - Cost is on the
invoke_agentspan (the session) and on eachchatspan. Sum one, never both. mode = "full"adds prompts, code and tool output as span attributes.- The
invoke_agentspan is sent when the session ends.coding.sh serveandcoding.sh collectoralso send it while a session is open: after 10 quiet seconds, and every 60 seconds while busy. - Each send is a new row with the same
TraceIdandSpanIdand newer totals. A session keyed by a conversation id never ends, so those are its only rows. - The queries below keep the row with the most events (
coding_sh.stats.events) for each span. - A laptop sink (
coding.sh ingest) sends the span at the session's end only. ttl: 720hdrops rows after 30 days. Set it to your retention.
Sample queries
Cost per agent and model per day:
SELECT
toDate(Timestamp) AS day,
SpanAttributes['gen_ai.agent.name'] AS agent,
SpanAttributes['gen_ai.request.model'] AS model,
count() AS sessions,
round(sum(toFloat64OrZero(SpanAttributes['coding_sh.cost_usd'])), 2) AS cost_usd,
sum(toUInt64OrZero(SpanAttributes['gen_ai.usage.input_tokens'])) AS input_tokens,
sum(toUInt64OrZero(SpanAttributes['gen_ai.usage.output_tokens'])) AS output_tokens
FROM (
SELECT Timestamp, SpanId, SpanAttributes
FROM otel.otel_traces
WHERE ServiceName = 'coding.sh'
AND SpanAttributes['gen_ai.operation.name'] = 'invoke_agent'
AND Timestamp >= now() - INTERVAL 30 DAY
ORDER BY toUInt64OrZero(SpanAttributes['coding_sh.stats.events']) DESC
LIMIT 1 BY SpanId
)
GROUP BY day, agent, model
ORDER BY day DESC, cost_usd DESC Slow and failing tools:
SELECT
SpanAttributes['gen_ai.tool.name'] AS tool,
count() AS calls,
countIf(StatusCode = 'Error') AS errors,
round(errors / calls, 3) AS error_rate,
round(quantile(0.95)(Duration) / 1e6) AS p95_ms,
round(max(Duration) / 1e6) AS max_ms
FROM otel.otel_traces
WHERE ServiceName = 'coding.sh'
AND SpanAttributes['gen_ai.operation.name'] = 'execute_tool'
AND Timestamp >= now() - INTERVAL 7 DAY
GROUP BY tool
ORDER BY p95_ms DESC
LIMIT 20 Failed sessions: tests failed, or coding.sh flagged repeated tool failures or a premature finish:
SELECT
Timestamp AS started,
SpanAttributes['gen_ai.conversation.id'] AS session,
SpanAttributes['gen_ai.agent.name'] AS agent,
toUInt32OrZero(SpanAttributes['coding_sh.stats.tool_errors']) AS tool_errors,
toUInt32OrZero(SpanAttributes['coding_sh.stats.tests_failed']) AS tests_failed,
JSONExtract(SpanAttributes['coding_sh.session.flags'], 'Array(String)') AS flags,
round(Duration / 1e9) AS duration_s
FROM (
SELECT Timestamp, SpanId, SpanAttributes, Duration
FROM otel.otel_traces
WHERE ServiceName = 'coding.sh'
AND SpanAttributes['gen_ai.operation.name'] = 'invoke_agent'
AND Timestamp >= now() - INTERVAL 7 DAY
ORDER BY toUInt64OrZero(SpanAttributes['coding_sh.stats.events']) DESC
LIMIT 1 BY SpanId
)
WHERE tests_failed > 0 OR hasAny(flags, ['repeated-tool-failure', 'premature-completion'])
ORDER BY started DESC
LIMIT 50 One session's spans, in order (clickhouse-client --param_session=<session id>):
SELECT
Timestamp,
SpanName,
round(Duration / 1e6) AS ms,
StatusCode,
SpanAttributes['coding_sh.cost_usd'] AS cost_usd
FROM (
SELECT Timestamp, SpanId, SpanName, Duration, StatusCode, SpanAttributes
FROM otel.otel_traces
WHERE TraceId = (
SELECT any(TraceId) FROM otel.otel_traces
WHERE SpanAttributes['gen_ai.conversation.id'] = {session:String}
)
ORDER BY toUInt64OrZero(SpanAttributes['coding_sh.stats.events']) DESC
LIMIT 1 BY SpanId
)
ORDER BY Timestamp Pull from ClickHouse
If your agents already send traces to a Collector with the clickhouse exporter (your own, ClickStack or HyperDX), coding.sh collector import reads them into your local store:
CLICKHOUSE_USER=otel CLICKHOUSE_PASSWORD=… \
coding.sh collector import --clickhouse "http://127.0.0.1:8123/?database=otel" - Each row is mapped as a span posted to
coding.sh collectorwould be: the same events, redaction and pricing. - It reads only traces with an agent span unless you pass
--all, and never stores a span twice. - It keeps its place per table and row filter. The next run starts an hour before it.
--sinceor--from-startreads from that time again; leave both out of a scheduled import. - It stores 5,000 rows at a time and retries a dropped connection or a 502, 503 or 504 three times. A run that still fails prints the command to run again.
- Both the default
Map(String, String)columns andjson: truecolumns work.
Every flag is on coding.sh collector.
S3 blobs
coding.sh-store-s3 keeps blobs (large prompts, tool output and transcripts) in AWS S3, Cloudflare R2, MinIO or any S3-compatible endpoint:
export AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=…
export CODING_SH_BLOB_URL='s3://my-bucket/blobs?region=us-east-1'
# R2: s3://bucket/blobs?region=auto&endpoint=https://<account>.r2.cloudflarestorage.com
# MinIO: s3://bucket/blobs?endpoint=http://127.0.0.1:9000 - Credentials come only from the environment. A URL with credentials in it is refused.
- Parameters:
region(defaultAWS_REGION, thenus-east-1),endpoint(defaultAWS_ENDPOINT_URL_S3, thenAWS_ENDPOINT_URL) andstyle=path|virtual. - The credentials need
s3:GetObject,s3:PutObject,s3:DeleteObjectands3:ListBucketon the bucket and prefix.
Next
- Self-host for a team: one server for the team's agents.
- Deploy coding.sh serve: Compose, Fly.io, Railway, Render and serverless.
- Configuration: every
[serve]key and its environment variable.