Docs Postgres and ClickHouse
View as Markdown

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

StoreCODING_SH_STORE_URLBlobsPick it when
SQLitesqlite:///data/index.db (the default)file:///data/blobsone machine, one team
Postgrespostgres://…the same database, or S3real traffic, several viewer replicas, the CLI on the team database
ClickHouseclickhouse://… or clickhouses://…file:// or S3, never ClickHousemany 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:

PackageURL scheme
coding.sh-store-postgrespostgres://, postgresql://
coding.sh-store-clickhouseclickhouse://, clickhouses://
coding.sh-store-s3s3:// (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

shell
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 parameterDefaultMeaning
schemapublicCreated if missing. Separate deployments can share a database with separate schemas.
pool_max10Connections in the pool. The writer lock uses one more.
anything elsePassed 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_URL to an s3:// 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 tsvector and needs no extensions. --where filters 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:

shell
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 pluginCollector route
What goes to ClickHouseEverything coding.sh stores: sessions, events, search text, eval assetsSpan metadata only, in the collector's otel_traces table
Who reads itcoding.sh (viewer, CLI, MCP) and your own SQLYour own SQL or Grafana; coding.sh collector import can pull agent traces back
What runscoding.sh serve with a clickhouse:// store URLYour OpenTelemetry Collector with the clickhouse exporter
Pick it whenClickHouse is the database you want coding.sh to run onYou already collect traces into ClickHouse

Run the server on ClickHouse

shell
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 partDefaultMeaning
schemeclickhouse is HTTP, clickhouses is HTTPS. ?secure=1 also turns on HTTPS.
port8123, or 8443 with TLSThe HTTP interface, not the native port 9000.
user, passworddefault, emptyErrors show the password as ***.
pathdefaultThe database. Created if missing.
anything elsePassed 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 uses FINAL, so the newest row wins. coding.sh gc --compact runs OPTIMIZE TABLE … FINAL.
  • serve takes a 30-second lease in the locks table, 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 Replicated engine before the first start. ClickHouse Cloud converts the tables to SharedMergeTree; add ?select_sequential_consistency=1 for 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:

shell
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:

yaml
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:

config.toml
[[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':

SpanNameOne perAttributes (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 callgen_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 callgen_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 toFloat64OrZero and toUInt64OrZero. coding_sh.session.flags is a JSON array string.
  • Cost is on the invoke_agent span (the session) and on each chat span. Sum one, never both.
  • mode = "full" adds prompts, code and tool output as span attributes.
  • The invoke_agent span is sent when the session ends. coding.sh serve and coding.sh collector also 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 TraceId and SpanId and 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: 720h drops rows after 30 days. Set it to your retention.

Sample queries

Cost per agent and model per day:

sql
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:

sql
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:

sql
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>):

sql
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:

shell
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 collector would 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. --since or --from-start reads 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 and json: true columns 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:

shell
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 (default AWS_REGION, then us-east-1), endpoint (default AWS_ENDPOINT_URL_S3, then AWS_ENDPOINT_URL) and style=path|virtual.
  • The credentials need s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket on the bucket and prefix.

Next