Generalize metrics pipeline: MetricDefinition + MetricsStreamConsumer

Extract the Redis Stream → ClickHouse pipeline into a generic
@internal/metrics-pipeline package. Queue metrics becomes the first
MetricDefinition implementation.

The generic pipeline provides:
- MetricDefinition interface (parse, dimension key, aggregate, insert)
- MetricsStreamConsumer (XREADGROUP, pre-aggregate, INSERT, XACK)
- MetricsStreamEmitter (convenience wrapper for Node.js producers)

Any part of the app can now ship metrics to ClickHouse by:
1. Creating a ClickHouse table
2. Implementing MetricDefinition
3. Emitting XADD entries (from Lua or Node.js)
4. Registering a consumer at startup

Includes worker health metrics as a second example definition.

https://claude.ai/code/session_01PkVYeZ8tQzgcT4YQsctGo1
This commit is contained in:
Claude
2026-02-07 10:04:25 +00:00
parent e254560f48
commit ccdac92e4c
+350 -64
View File
@@ -503,9 +503,346 @@ These are the new user-facing metrics enabled by this system:
---
## 6. Implementation Plan
## 6. Generic Metrics Pipeline
### Phase 1: Lua script changes
The transport layer (Redis Stream → Consumer → ClickHouse) is not queue-specific. It should be built as a generic pipeline that any part of the application can use to ship metrics to ClickHouse. Queue metrics is the first consumer.
### 6.1 Architecture
```
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Queue Lua Scripts │ │ Worker Health │ │ Future: API Metrics│
│ (XADD in Lua) │ │ (XADD from Node.js) │ │ (XADD from Node.js)│
└──────────┬───────────┘ └──────────┬───────────┘ └──────────┬───────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ metrics:queue:0 │ │ metrics:worker:0 │ │ metrics:api:0 │
│ metrics:queue:1 │ │ metrics:worker:1 │ │ metrics:api:1 │
│ (Redis Streams) │ │ (Redis Streams) │ │ (Redis Streams) │
└──────────┬───────┘ └──────────┬───────┘ └──────────┬───────┘
│ │ │
└───────────┬───────────┘───────────────────────┘
┌──────────────────────────────┐
│ MetricsStreamConsumer │
│ (generic, one per metric │
│ definition) │
│ │
│ - XREADGROUP per shard │
│ - pre-aggregate via │
│ MetricDefinition │
│ - INSERT into target table │
│ - XACK on success │
└──────────────────────────────┘
```
### 6.2 MetricDefinition interface
Each metric type registers a definition that tells the pipeline how to parse, aggregate, and store its data:
```typescript
/**
* Defines a metric type for the generic Redis Stream → ClickHouse pipeline.
*
* The pipeline handles: stream consumption, consumer groups, PEL recovery,
* retry with backoff, batching, and graceful shutdown.
*
* The metric definition handles: what the data looks like, how to aggregate
* it, and where it goes.
*/
interface MetricDefinition<TEntry, TAggregated> {
/** Unique name for this metric (used in stream keys, consumer groups) */
name: string;
/** Target ClickHouse table for inserts */
clickhouseTable: string;
/** Number of stream shards (streams are named `metrics:{name}:{shard}`) */
shardCount: number;
/** MAXLEN for each stream shard */
maxStreamLength: number;
/** Bucket size in milliseconds for pre-aggregation */
bucketSizeMs: number;
/**
* Parse a raw Redis Stream entry (string key-value pairs)
* into a typed entry. Return null to skip/filter the entry.
*/
parseEntry(fields: Record<string, string>, streamId: string): TEntry | null;
/**
* Extract the dimension key for grouping.
* Entries with the same dimension key and time bucket are aggregated together.
* Returns a string that uniquely identifies the dimension combination.
*/
dimensionKey(entry: TEntry): string;
/**
* Extract the timestamp from a parsed entry (ms since epoch).
* Used to assign entries to time buckets.
*/
timestamp(entry: TEntry): number;
/**
* Aggregate a batch of entries that share the same dimension key
* and time bucket into a single row for ClickHouse insertion.
*/
aggregate(dimensionKey: string, bucketStart: Date, entries: TEntry[]): TAggregated;
/**
* Convert aggregated rows into the format expected by the ClickHouse client.
* Returns column names and values for JSONEachRow insert.
*/
toInsertRow(row: TAggregated): Record<string, unknown>;
}
```
### 6.3 MetricsStreamConsumer (generic pipeline)
```typescript
/**
* Generic consumer that reads from Redis Streams and inserts into ClickHouse.
* One instance per MetricDefinition.
*/
class MetricsStreamConsumer<TEntry, TAggregated> {
constructor(options: {
redis: RedisOptions;
clickhouse: ClickHouseClient;
definition: MetricDefinition<TEntry, TAggregated>;
consumerGroup: string;
consumerId: string;
pollIntervalMs?: number; // default: 1000
batchSize?: number; // default: 1000
}) {}
async start(): Promise<void> {
// For each shard:
// 1. XGROUP CREATE metrics:{name}:{shard} {consumerGroup} $ MKSTREAM
// 2. Start polling loop
}
private async pollShard(shard: number): Promise<void> {
// 1. Read pending entries first (PEL recovery): XREADGROUP ... 0
// - INSERT these as a separate batch (enables CH insert dedup)
// - XACK on success
// 2. Read new entries: XREADGROUP ... >
// - Parse via definition.parseEntry()
// - Group by definition.dimensionKey() + time bucket
// - Aggregate via definition.aggregate()
// - Convert via definition.toInsertRow()
// - INSERT batch into definition.clickhouseTable
// - XACK on success
// 3. On failure: back off, retry from PEL next iteration
}
async stop(): Promise<void> {
// Signal shutdown, drain in-flight batches
}
}
```
### 6.4 MetricsStreamEmitter (convenience for Node.js producers)
For metrics emitted from Node.js (not Lua), provide a thin helper:
```typescript
/**
* Emits metric entries to a Redis Stream. For use in Node.js code.
* Lua scripts use XADD directly — this is for non-Lua producers.
*/
class MetricsStreamEmitter {
constructor(options: {
redis: Redis;
streamPrefix: string; // e.g., "metrics"
metricName: string; // e.g., "worker_health"
shardCount: number;
maxStreamLength?: number; // default: 100000
}) {}
/**
* Emit a metric entry to the appropriate shard.
* Shard selection can be based on a dimension value (e.g., envId)
* for locality, or round-robin.
*/
async emit(
fields: Record<string, string | number>,
shardKey?: string
): Promise<void> {
const shard = shardKey
? jumpHash(shardKey, this.shardCount)
: this.roundRobinShard();
const streamKey = `${this.streamPrefix}:${this.metricName}:${shard}`;
await this.redis.xadd(
streamKey, 'MAXLEN', '~', this.maxStreamLength.toString(), '*',
...Object.entries(fields).flat()
);
}
}
```
### 6.5 Queue metrics as the first MetricDefinition
```typescript
const queueMetricsDefinition: MetricDefinition<QueueMetricEntry, QueueMetricRow> = {
name: "queue",
clickhouseTable: "queue_metrics_5s_v1",
shardCount: 2, // match RunQueue shard count
maxStreamLength: 100_000,
bucketSizeMs: 5_000, // 5 seconds
parseEntry(fields, streamId) {
return {
organizationId: fields.org,
projectId: fields.proj,
environmentId: fields.env,
queueName: fields.queue,
operation: fields.op,
timestamp: redisStreamIdToMs(streamId),
queueLength: parseInt(fields.ql ?? "0"),
concurrencyCurrent: parseInt(fields.cc ?? "0"),
envQueueLength: parseInt(fields.eql ?? "0"),
envConcurrency: parseInt(fields.ec ?? "0"),
oldestMessageAgeMs: parseInt(fields.age ?? "0"),
enqueueCount: parseInt(fields.eq ?? "0"),
dequeueCount: parseInt(fields.dq ?? "0"),
ackCount: parseInt(fields.ak ?? "0"),
nackCount: parseInt(fields.nk ?? "0"),
dlqCount: parseInt(fields.dlq ?? "0"),
ttlExpireCount: parseInt(fields.ttl ?? "0"),
waitDurationMs: parseInt(fields.wd ?? "0"),
};
},
dimensionKey(entry) {
return `${entry.organizationId}:${entry.projectId}:${entry.environmentId}:${entry.queueName}`;
},
timestamp(entry) {
return entry.timestamp;
},
aggregate(dimensionKey, bucketStart, entries) {
const [orgId, projId, envId, queue] = dimensionKey.split(":");
return {
organization_id: orgId,
project_id: projId,
environment_id: envId,
queue_name: queue,
bucket_start: bucketStart,
enqueue_count: sum(entries, "enqueueCount"),
dequeue_count: sum(entries, "dequeueCount"),
ack_count: sum(entries, "ackCount"),
nack_count: sum(entries, "nackCount"),
dlq_count: sum(entries, "dlqCount"),
ttl_expire_count: sum(entries, "ttlExpireCount"),
max_queue_length: max(entries, "queueLength"),
max_concurrency_current: max(entries, "concurrencyCurrent"),
max_env_queue_length: max(entries, "envQueueLength"),
max_env_concurrency: max(entries, "envConcurrency"),
max_oldest_message_age_ms: max(entries, "oldestMessageAgeMs"),
total_wait_duration_ms: sum(entries, "waitDurationMs"),
wait_duration_count: countNonZero(entries, "waitDurationMs"),
};
},
toInsertRow(row) {
return { ...row, bucket_start: formatDateTime(row.bucket_start) };
},
};
```
### 6.6 Example: adding a second metric type
To ship a new metric to ClickHouse, you only need:
1. **A ClickHouse table** (+ optional MVs for rollup)
2. **A MetricDefinition** implementation
3. **XADD calls** at the emission points (Lua or Node.js)
4. **Register the consumer** at startup
For example, worker health metrics:
```typescript
const workerHealthDefinition: MetricDefinition<WorkerHealthEntry, WorkerHealthRow> = {
name: "worker_health",
clickhouseTable: "worker_health_5s_v1",
shardCount: 1,
maxStreamLength: 50_000,
bucketSizeMs: 5_000,
parseEntry(fields, streamId) {
return {
workerId: fields.wid,
environmentId: fields.env,
timestamp: redisStreamIdToMs(streamId),
cpuPercent: parseFloat(fields.cpu ?? "0"),
memoryMb: parseInt(fields.mem ?? "0"),
activeConnections: parseInt(fields.conn ?? "0"),
};
},
dimensionKey(entry) { return `${entry.environmentId}:${entry.workerId}`; },
timestamp(entry) { return entry.timestamp; },
aggregate(dimensionKey, bucketStart, entries) {
const [envId, workerId] = dimensionKey.split(":");
return {
environment_id: envId,
worker_id: workerId,
bucket_start: bucketStart,
max_cpu_percent: max(entries, "cpuPercent"),
max_memory_mb: max(entries, "memoryMb"),
max_active_connections: max(entries, "activeConnections"),
sample_count: entries.length,
};
},
toInsertRow(row) { return { ...row, bucket_start: formatDateTime(row.bucket_start) }; },
};
// At startup:
const workerHealthConsumer = new MetricsStreamConsumer({
redis: redisOptions,
clickhouse: clickhouseClient,
definition: workerHealthDefinition,
consumerGroup: "worker_health_cg",
consumerId: `consumer_${process.pid}`,
});
await workerHealthConsumer.start();
```
### 6.7 Where to put the generic pipeline
```
internal-packages/
metrics-pipeline/ # NEW package: @internal/metrics-pipeline
src/
types.ts # MetricDefinition interface
consumer.ts # MetricsStreamConsumer
emitter.ts # MetricsStreamEmitter
helpers.ts # sum(), max(), countNonZero(), redisStreamIdToMs()
index.ts # public exports
run-engine/
src/run-queue/
queueMetrics.ts # queueMetricsDefinition (implements MetricDefinition)
index.ts # Lua scripts with XADD emission
```
The generic pipeline lives in its own internal package so it can be used by any app (webapp, supervisor) without depending on run-engine.
---
## 7. Queue-Specific Implementation Plan
### Phase 1: Generic pipeline package
Create `@internal/metrics-pipeline` with `MetricDefinition`, `MetricsStreamConsumer`, `MetricsStreamEmitter`, and helpers. This is framework code with no queue-specific logic.
### Phase 2: Lua script changes
Modify each Lua script to accept an additional KEYS entry (the metrics stream key) and emit an XADD at the end. The additional KEYS/ARGV entries to pass:
@@ -528,66 +865,15 @@ pcall(function()
end)
```
### Phase 2: Stream consumer
### Phase 3: Queue metric definition + consumer wiring
Create a new service class `QueueMetricsConsumer` in `internal-packages/run-engine/src/run-queue/`:
Create `queueMetricsDefinition` (section 6.5) and wire a `MetricsStreamConsumer` for it in the webapp startup. The queue metric definition specifies the 5s bucket size, the target ClickHouse table, and the aggregation logic.
```typescript
export class QueueMetricsConsumer {
constructor(options: {
redis: RedisOptions;
clickhouse: ClickHouseClient;
shardCount: number;
consumerGroup: string;
consumerId: string;
pollIntervalMs?: number; // default: 1000
batchSize?: number; // default: 1000
maxRetries?: number; // default: 3
}) {}
async start(): Promise<void> {
// 1. Create consumer group if not exists (XGROUP CREATE ... MKSTREAM)
// 2. Start polling loop for each shard
}
private async pollShard(shard: number): Promise<void> {
// 1. First, check for pending (unacked) entries: XREADGROUP ... 0
// 2. Then read new entries: XREADGROUP ... >
// 3. Pre-aggregate entries into 5s buckets in memory (see below)
// 4. Bulk INSERT aggregated rows into queue_metrics_5s_v1
// 5. On success: XACK all processed IDs
// 6. On failure: back off, retry from PEL next iteration
}
/**
* Pre-aggregates raw stream entries into 5-second buckets.
*
* Groups entries by (org, project, env, queue, floor(timestamp / 5000))
* and computes:
* - Counters: sum of enqueue_count, dequeue_count, etc.
* - Gauges: max of queue_length, concurrency_current, etc.
* - Latency: sum of wait_duration_ms, count of non-zero waits
*
* This reduces N raw stream entries into M << N aggregated rows
* (one per active queue per 5s window in the batch).
*
* SummingMergeTree handles the case where two consumer batches
* produce rows for the same 5s bucket — they merge correctly
* on background merge.
*/
private preAggregate(entries: StreamEntry[]): AggregatedRow[] { ... }
async stop(): Promise<void> {
// Graceful shutdown
}
}
```
### Phase 3: ClickHouse migration
### Phase 4: ClickHouse migration
Add migration `016_add_queue_metrics.sql` with the 5s table, minute/hour tables, and the two materialized views (5s→minute, minute→hour) from section 3.
### Phase 4: API and presenters
### Phase 5: API and presenters
- New `QueueMetricsPresenter` that queries the 5s/minute/hour tables (auto-selects based on time range)
- New API endpoint `GET /api/v1/queues/:queueParam/metrics`
@@ -595,7 +881,7 @@ Add migration `016_add_queue_metrics.sql` with the 5s table, minute/hour tables,
---
## 7. Alerting Architecture
## 8. Alerting Architecture
### How alerts fit in
@@ -699,7 +985,7 @@ When the condition is no longer met, the evaluator can optionally auto-resolve:
---
## 8. Risks
## 9. Risks
### Risk 1: Double-counting on consumer crash (MEDIUM)
@@ -765,7 +1051,7 @@ Adding a metrics stream key (e.g., `queue_metrics:shard:0`) to these Lua scripts
---
## 9. Metric Importance Ranking
## 10. Metric Importance Ranking
Ranked by user value — how directly the metric answers questions users actually ask.
@@ -827,7 +1113,7 @@ Ranked by user value — how directly the metric answers questions users actuall
---
## 10. Performance Considerations
## 11. Performance Considerations
### Redis impact
@@ -872,7 +1158,7 @@ The critical scaling property: a queue that processes 1 event/5s and a queue tha
---
## 11. Failure Modes and Recovery
## 12. Failure Modes and Recovery
| Failure | Impact | Recovery |
|---------|--------|----------|
@@ -886,7 +1172,7 @@ The key invariant: **queue operations (enqueue/dequeue/ack) are never blocked or
---
## 12. Migration and Rollout Strategy
## 13. Migration and Rollout Strategy
1. **Feature flag**: Pass a metrics-enabled flag as an ARGV to Lua scripts from Node.js (see Risk 5 — avoids an extra GET on every Lua invocation). The Node.js layer caches the flag from a Redis key (`queue_metrics:enabled`) and refreshes every 10s:
```lua
@@ -906,7 +1192,7 @@ The key invariant: **queue operations (enqueue/dequeue/ack) are never blocked or
---
## 13. Summary of Tradeoffs
## 14. Summary of Tradeoffs
| Decision | Alternative | Why This Choice |
|----------|-------------|-----------------|