khipu (/ˈkiːpuː/) — an Inca method for recording information with knotted cords.
khipu is an OTLP metrics ingest service written in Go.
It receives OTLP/gRPC metrics, extracts series metadata into a lookup table, and stores datapoints in ClickHouse with a SeriesID reference.
Supported metric types today:
- Gauge
- Sum
- Go 1.26+
- Docker
make db-upmake runBy default, khipu listens on localhost:4317 (OTLP gRPC).
make metricgenmake chmake db-downFor query examples and a complete walkthrough, see:
Use:
make helpThis prints all available targets and descriptions (build, run, db-up/down, metricgen, test, test-integration, test-all, and more).
Configuration is loaded from .env / environment variables.
You can start from:
cp .env.example .envMain variables:
| Env var | Default | Description |
|---|---|---|
LISTEN_ADDR |
localhost:4317 |
OTLP gRPC listen address |
CLICKHOUSE_ADDR |
localhost:9000 |
ClickHouse native endpoint |
CLICKHOUSE_DATABASE |
default |
ClickHouse database |
CLICKHOUSE_USER |
default |
ClickHouse user |
CLICKHOUSE_PASSWORD |
dev |
ClickHouse password |
SERIES_CACHE_CAPACITY |
100000 |
In-memory series cache size |
MAX_RECV_MSG_SIZE |
16777216 |
Max gRPC receive message size |
DASH0_ENABLED |
false |
Export service telemetry via OTLP |
OTEL_EXPORTER_OTLP_ENDPOINT |
(empty) | OTLP endpoint when Dash0/remote export is enabled |
Metric generator variables:
| Env var | Default | Description |
|---|---|---|
GEN_INTERVAL |
1s |
Export interval |
GEN_SERVICES |
3 |
Number of simulated services |
.
├── bin # local build artifacts (generated binaries)
├── build # local infrastructure files (docker-compose)
├── cmd # executable entrypoints
│ ├── khipu # ingest service (OTLP gRPC -> ClickHouse)
│ └── metricgen # synthetic OTLP traffic generator
└── internal # private application packages
├── ingest # gRPC handler, mapping, and request orchestration
├── series # deterministic SeriesID generation and cache
├── store # storage interfaces
│ └── clickhouse # ClickHouse schema and insert implementation
└── telemetry # OpenTelemetry SDK bootstrap and exporters
flowchart LR
MG[metricgen / OTLP client] --> H[ingest.Handler]
H --> M[mapper + SeriesID]
H --> C[series.Cache]
H --> S[store/clickhouse]
S --> MS[(metric_series)]
S --> G[(otel_metrics_gauge)]
S --> SU[(otel_metrics_sum)]
G --> MV1[otel_metrics_gauge_1m_mv]
MV1 --> G1[(otel_metrics_gauge_1m)]
MS -. refresh .-> D[[metric_series_dict]]
G -. dictGet by SeriesID .-> D
G1 -. dictGet by SeriesID .-> D
SU -. dictGet by SeriesID .-> D
sequenceDiagram
participant Client as OTLP Client / metricgen
participant Handler as ingest.Handler
participant Mapper as mapper
participant Cache as series.Cache
participant Store as MetricsStore
participant CH as ClickHouse
Client->>Handler: Export(ResourceMetrics)
Handler->>Mapper: mapGaugeRows + mapSumRows
Mapper-->>Handler: seriesRows + datapointRows
Handler->>Handler: deduplicateSeries()
Handler->>Cache: filterNewSeries()
Cache-->>Handler: newSeries
alt newSeries not empty
Handler->>Store: InsertSeries(newSeries)
Store->>CH: INSERT metric_series
end
par gauge
Handler->>Store: InsertGauge(gaugeRows)
Store->>CH: INSERT otel_metrics_gauge
CH->>CH: MV aggregates into otel_metrics_gauge_1m
and sum
Handler->>Store: InsertSum(sumRows)
Store->>CH: INSERT otel_metrics_sum
end
Handler->>Cache: Add(newSeries IDs)
Handler-->>Client: ExportResponse OK
Stores series metadata (resource/scope/metric/attributes). Uses ReplacingMergeTree ordered by (MetricName, SeriesID) — rows are deduplicated on that composite key, keeping the latest LastSeenAt.
Store:
SeriesID— reference tometric_series- timestamps
- value
Attributes— datapoint-level attributes, inline with bloom filter indexes (ClickHouse dictionaries cannot storeMaptypes; inline storage enables attribute filtering without a JOIN)
Each insert into otel_metrics_gauge triggers the materialized view otel_metrics_gauge_1m_mv, which groups rows by minute and SeriesID into otel_metrics_gauge_1m (ValueSum, ValueCount; average = ValueSum / ValueCount). Use for dashboard-style time-range queries without scanning raw samples.
Provides query-time metadata lookup via dictGet without explicit JOINs.
- the design is optimized for time-window queries
- series metadata inserts are reduced by a bounded in-memory cache
ReplacingMergeTreeinmetric_serieshandles duplicate series metadata safely