From ddc9e08576d739e6282b5582c1bcc6472a59e0c4 Mon Sep 17 00:00:00 2001 From: charlieww Date: Thu, 26 Mar 2026 22:56:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20feat:=20Define=20metric=20schema=20?= =?UTF-8?q?and=20TypeScript=20types=20=E2=80=94=20src/types/metrics.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/metrics.ts | 180 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/types/metrics.ts diff --git a/src/types/metrics.ts b/src/types/metrics.ts new file mode 100644 index 0000000..665af5d --- /dev/null +++ b/src/types/metrics.ts @@ -0,0 +1,180 @@ +import { z } from 'zod' + +// ─── Primitive schemas ─────────────────────────────────────────────────────── + +/** + * Key/value labels used to differentiate metric streams. + * e.g. { service: "api", region: "us-east-1" } + */ +const LabelsSchema = z.record(z.string(), z.string()) + +// ─── MetricEvent ───────────────────────────────────────────────────────────── + +/** + * A single metric measurement emitted by the server and consumed by the client + * over WebSocket. This is the atomic unit of the metrics pipeline. + */ +export const MetricEventSchema = z.object({ + /** Dot-namespaced metric identifier, e.g. "agent.task.duration_ms" */ + metric_name: z.string().min(1), + /** Measured value */ + value: z.number(), + /** Unix epoch milliseconds */ + timestamp: z.number().int().positive(), + /** Dimensional labels for fanout/filtering */ + labels: LabelsSchema.optional(), + /** SI unit string, e.g. "ms", "bytes", "percent" */ + unit: z.string().optional(), +}) + +export type MetricEvent = z.infer + +// ─── MetricSeries ───────────────────────────────────────────────────────────── + +/** + * An ordered sequence of data points for a single metric stream over a + * bounded time window. Used when the server pushes a batch of historical or + * buffered points (e.g. on initial WebSocket subscription). + */ +export const MetricSeriesSchema = z.object({ + metric_name: z.string().min(1), + labels: LabelsSchema.optional(), + unit: z.string().optional(), + /** Ordered ascending by timestamp */ + data_points: z.array( + z.object({ + value: z.number(), + timestamp: z.number().int().positive(), + }) + ), + /** Inclusive start of the window (Unix ms) */ + window_start: z.number().int().positive(), + /** Inclusive end of the window (Unix ms) */ + window_end: z.number().int().positive(), +}).refine( + (s) => s.window_end >= s.window_start, + { message: 'window_end must be >= window_start', path: ['window_end'] } +) + +export type MetricSeries = z.infer + +// ─── AggregatedMetric ──────────────────────────────────────────────────────── + +/** + * Pre-computed statistics over a metric stream for a given aggregation window. + * Emitted by the server after rolling up raw events so clients do not need to + * perform their own aggregation. + */ +export const AggregatedMetricSchema = z.object({ + metric_name: z.string().min(1), + labels: LabelsSchema.optional(), + unit: z.string().optional(), + /** Inclusive start of the aggregation window (Unix ms) */ + window_start: z.number().int().positive(), + /** Inclusive end of the aggregation window (Unix ms) */ + window_end: z.number().int().positive(), + /** Number of raw events that were aggregated */ + count: z.number().int().nonnegative(), + sum: z.number(), + min: z.number(), + max: z.number(), + /** Arithmetic mean */ + avg: z.number(), + /** 50th percentile (median) — omitted when count < 2 */ + p50: z.number().optional(), + /** 90th percentile — omitted when count < 10 */ + p90: z.number().optional(), + /** 99th percentile — omitted when count < 100 */ + p99: z.number().optional(), +}).refine( + (a) => a.window_end >= a.window_start, + { message: 'window_end must be >= window_start', path: ['window_end'] } +).refine( + (a) => a.count === 0 || a.min <= a.max, + { message: 'min must be <= max when count > 0', path: ['min'] } +) + +export type AggregatedMetric = z.infer + +// ─── WebSocket message envelope ─────────────────────────────────────────────── + +/** + * Discriminated union of all messages the server can push to a client. + * Using a `type` discriminant keeps the envelope consistent and allows + * exhaustive switch handling on the client side. + */ +export const ServerMetricMessageSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('metric_event'), payload: MetricEventSchema }), + z.object({ type: z.literal('metric_series'), payload: MetricSeriesSchema }), + z.object({ type: z.literal('aggregated_metric'), payload: AggregatedMetricSchema }), + z.object({ + type: z.literal('error'), + payload: z.object({ + code: z.string(), + message: z.string(), + }), + }), +]) + +export type ServerMetricMessage = z.infer + +/** + * Subscription request sent by the client to the server. + */ +export const MetricSubscriptionSchema = z.object({ + /** Unique ID so the client can correlate acknowledgements and cancel by ID */ + subscription_id: z.string().min(1), + /** Glob-style metric name filter, e.g. "agent.*" or "agent.task.duration_ms" */ + metric_name_filter: z.string().min(1), + /** Optional label equality constraints */ + label_filter: LabelsSchema.optional(), + /** + * If set, the server will also push an AggregatedMetric on this interval + * in addition to raw MetricEvents. + */ + aggregation_window_ms: z.number().int().positive().optional(), +}) + +export type MetricSubscription = z.infer + +/** + * Discriminated union of all messages the client can send to the server. + */ +export const ClientMetricMessageSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('subscribe'), payload: MetricSubscriptionSchema }), + z.object({ + type: z.literal('unsubscribe'), + payload: z.object({ subscription_id: z.string().min(1) }), + }), +]) + +export type ClientMetricMessage = z.infer + +// ─── Parse helpers ──────────────────────────────────────────────────────────── + +/** + * Parse an unknown WebSocket message payload from the server. + * Throws a ZodError on invalid shape — callers should handle and close the + * connection or emit a warning rather than crashing. + */ +export function parseServerMessage(raw: unknown): ServerMetricMessage { + return ServerMetricMessageSchema.parse(raw) +} + +/** + * Parse an unknown WebSocket message payload from the client. + * Throws a ZodError on invalid shape. + */ +export function parseClientMessage(raw: unknown): ClientMetricMessage { + return ClientMetricMessageSchema.parse(raw) +} + +/** + * Safe variant — returns null instead of throwing. + * Useful in fire-and-forget consumers where a bad frame should be logged + * and skipped rather than crashing the subscription loop. + */ +export function safeParseServerMessage(raw: unknown): ServerMetricMessage | null { + const result = ServerMetricMessageSchema.safeParse(raw) + return result.success ? result.data : null +} From 756da0ccd27150d37719c62b4244527831be3e64 Mon Sep 17 00:00:00 2001 From: charlieww Date: Thu, 26 Mar 2026 22:56:32 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20feat:=20Define=20metric=20schema=20?= =?UTF-8?q?and=20TypeScript=20types=20=E2=80=94=20src/types/metrics.test.t?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/metrics.test.ts | 289 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 src/types/metrics.test.ts diff --git a/src/types/metrics.test.ts b/src/types/metrics.test.ts new file mode 100644 index 0000000..582db18 --- /dev/null +++ b/src/types/metrics.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect } from 'vitest' +import { + MetricEventSchema, + MetricSeriesSchema, + AggregatedMetricSchema, + ServerMetricMessageSchema, + ClientMetricMessageSchema, + parseServerMessage, + parseClientMessage, + safeParseServerMessage, +} from './metrics.js' + +// ─── MetricEvent ────────────────────────────────────────────────────────────── + +describe('MetricEventSchema', () => { + const valid = { + metric_name: 'agent.task.duration_ms', + value: 142.5, + timestamp: 1_700_000_000_000, + labels: { service: 'agent-runtime' }, + unit: 'ms', + } + + it('should accept a fully-populated event', () => { + expect(() => MetricEventSchema.parse(valid)).not.toThrow() + }) + + it('should accept an event with no optional fields', () => { + const minimal = { metric_name: 'cpu', value: 0.5, timestamp: 1_700_000_000_000 } + expect(() => MetricEventSchema.parse(minimal)).not.toThrow() + }) + + it('should reject an empty metric_name', () => { + expect(() => MetricEventSchema.parse({ ...valid, metric_name: '' })).toThrow() + }) + + it('should reject a non-integer timestamp', () => { + expect(() => MetricEventSchema.parse({ ...valid, timestamp: 1.5 })).toThrow() + }) + + it('should reject a negative timestamp', () => { + expect(() => MetricEventSchema.parse({ ...valid, timestamp: -1 })).toThrow() + }) + + it('should reject non-string label values', () => { + expect(() => + MetricEventSchema.parse({ ...valid, labels: { env: 42 } }) + ).toThrow() + }) +}) + +// ─── MetricSeries ───────────────────────────────────────────────────────────── + +describe('MetricSeriesSchema', () => { + const valid = { + metric_name: 'memory.heap_used_bytes', + unit: 'bytes', + data_points: [ + { value: 1024, timestamp: 1_700_000_000_000 }, + { value: 2048, timestamp: 1_700_000_001_000 }, + ], + window_start: 1_700_000_000_000, + window_end: 1_700_000_001_000, + } + + it('should accept a valid series', () => { + expect(() => MetricSeriesSchema.parse(valid)).not.toThrow() + }) + + it('should accept a series with zero data points', () => { + expect(() => + MetricSeriesSchema.parse({ ...valid, data_points: [] }) + ).not.toThrow() + }) + + it('should reject when window_end is before window_start', () => { + expect(() => + MetricSeriesSchema.parse({ ...valid, window_end: valid.window_start - 1 }) + ).toThrow() + }) + + it('should accept when window_end equals window_start (point-in-time window)', () => { + expect(() => + MetricSeriesSchema.parse({ ...valid, window_end: valid.window_start }) + ).not.toThrow() + }) + + it('should reject a data_point with a non-integer timestamp', () => { + const bad = [ + { value: 1, timestamp: 1.9 }, + ] + expect(() => MetricSeriesSchema.parse({ ...valid, data_points: bad })).toThrow() + }) +}) + +// ─── AggregatedMetric ──────────────────────────────────────────────────────── + +describe('AggregatedMetricSchema', () => { + const valid = { + metric_name: 'http.request.latency_ms', + unit: 'ms', + window_start: 1_700_000_000_000, + window_end: 1_700_000_060_000, + count: 5, + sum: 350, + min: 50, + max: 120, + avg: 70, + p50: 65, + p90: 110, + } + + it('should accept a fully-populated aggregation', () => { + expect(() => AggregatedMetricSchema.parse(valid)).not.toThrow() + }) + + it('should accept an aggregation with no percentile fields', () => { + const { p50, p90, ...minimal } = valid + expect(() => AggregatedMetricSchema.parse(minimal)).not.toThrow() + }) + + it('should accept a zero-count aggregation where min equals max', () => { + expect(() => + AggregatedMetricSchema.parse({ ...valid, count: 0, sum: 0, min: 0, max: 0, avg: 0 }) + ).not.toThrow() + }) + + it('should reject when window_end is before window_start', () => { + expect(() => + AggregatedMetricSchema.parse({ ...valid, window_end: valid.window_start - 1 }) + ).toThrow() + }) + + it('should reject when min is greater than max and count > 0', () => { + expect(() => + AggregatedMetricSchema.parse({ ...valid, min: 200, max: 50 }) + ).toThrow() + }) + + it('should reject a negative count', () => { + expect(() => AggregatedMetricSchema.parse({ ...valid, count: -1 })).toThrow() + }) +}) + +// ─── ServerMetricMessage ───────────────────────────────────────────────────── + +describe('ServerMetricMessageSchema', () => { + it('should accept a metric_event message', () => { + const msg = { + type: 'metric_event', + payload: { metric_name: 'cpu', value: 0.8, timestamp: 1_700_000_000_000 }, + } + expect(() => ServerMetricMessageSchema.parse(msg)).not.toThrow() + }) + + it('should accept a metric_series message', () => { + const msg = { + type: 'metric_series', + payload: { + metric_name: 'cpu', + data_points: [], + window_start: 1_700_000_000_000, + window_end: 1_700_000_060_000, + }, + } + expect(() => ServerMetricMessageSchema.parse(msg)).not.toThrow() + }) + + it('should accept an aggregated_metric message', () => { + const msg = { + type: 'aggregated_metric', + payload: { + metric_name: 'cpu', + window_start: 1_700_000_000_000, + window_end: 1_700_000_060_000, + count: 0, + sum: 0, + min: 0, + max: 0, + avg: 0, + }, + } + expect(() => ServerMetricMessageSchema.parse(msg)).not.toThrow() + }) + + it('should accept an error message', () => { + const msg = { type: 'error', payload: { code: 'NOT_FOUND', message: 'metric not found' } } + expect(() => ServerMetricMessageSchema.parse(msg)).not.toThrow() + }) + + it('should reject an unknown type discriminant', () => { + const msg = { type: 'unknown_type', payload: {} } + expect(() => ServerMetricMessageSchema.parse(msg)).toThrow() + }) + + it('should reject a metric_event message with an invalid payload', () => { + const msg = { type: 'metric_event', payload: { metric_name: '', value: 1, timestamp: 1_700_000_000_000 } } + expect(() => ServerMetricMessageSchema.parse(msg)).toThrow() + }) +}) + +// ─── ClientMetricMessage ───────────────────────────────────────────────────── + +describe('ClientMetricMessageSchema', () => { + it('should accept a subscribe message', () => { + const msg = { + type: 'subscribe', + payload: { + subscription_id: 'sub-1', + metric_name_filter: 'agent.*', + label_filter: { env: 'prod' }, + aggregation_window_ms: 60_000, + }, + } + expect(() => ClientMetricMessageSchema.parse(msg)).not.toThrow() + }) + + it('should accept an unsubscribe message', () => { + const msg = { type: 'unsubscribe', payload: { subscription_id: 'sub-1' } } + expect(() => ClientMetricMessageSchema.parse(msg)).not.toThrow() + }) + + it('should reject a subscribe message with an empty subscription_id', () => { + const msg = { + type: 'subscribe', + payload: { subscription_id: '', metric_name_filter: 'cpu' }, + } + expect(() => ClientMetricMessageSchema.parse(msg)).toThrow() + }) + + it('should reject a subscribe message with an empty metric_name_filter', () => { + const msg = { + type: 'subscribe', + payload: { subscription_id: 'sub-1', metric_name_filter: '' }, + } + expect(() => ClientMetricMessageSchema.parse(msg)).toThrow() + }) +}) + +// ─── Parse helpers ──────────────────────────────────────────────────────────── + +describe('parseServerMessage', () => { + it('should return a typed message on valid input', () => { + const raw = { + type: 'metric_event', + payload: { metric_name: 'cpu', value: 0.5, timestamp: 1_700_000_000_000 }, + } + const msg = parseServerMessage(raw) + expect(msg.type).toBe('metric_event') + }) + + it('should throw on invalid input', () => { + expect(() => parseServerMessage({ type: 'bad' })).toThrow() + }) +}) + +describe('parseClientMessage', () => { + it('should return a typed message on valid input', () => { + const raw = { + type: 'unsubscribe', + payload: { subscription_id: 'sub-99' }, + } + const msg = parseClientMessage(raw) + expect(msg.type).toBe('unsubscribe') + }) + + it('should throw on invalid input', () => { + expect(() => parseClientMessage(null)).toThrow() + }) +}) + +describe('safeParseServerMessage', () => { + it('should return the message on valid input', () => { + const raw = { + type: 'error', + payload: { code: 'TIMEOUT', message: 'upstream timed out' }, + } + expect(safeParseServerMessage(raw)).not.toBeNull() + }) + + it('should return null on invalid input without throwing', () => { + expect(() => safeParseServerMessage({ type: 'garbage' })).not.toThrow() + expect(safeParseServerMessage({ type: 'garbage' })).toBeNull() + }) + + it('should return null for null input', () => { + expect(safeParseServerMessage(null)).toBeNull() + }) +})