mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-06-30 17:09:38 +02:00
Migrate metrics to Effect primitives
This commit is contained in:
parent
39db6d8235
commit
0fb10aca73
8 changed files with 197 additions and 56 deletions
|
|
@ -1450,6 +1450,33 @@ Notes:
|
|||
- `cd ts && bun run lint`
|
||||
- `git diff --check`
|
||||
|
||||
### 2026-06-02: Effect Metrics Prometheus Slice
|
||||
|
||||
- Status: migrated and root-verified.
|
||||
- Completed:
|
||||
- Replaced the `@trustgraph/base` `prom-client` metric wrappers with
|
||||
Effect-native `Metric.counter`, `Metric.histogram`, and
|
||||
`effect/unstable/observability` `PrometheusMetrics.format`.
|
||||
- Kept the existing Prometheus metric names and gateway
|
||||
`/api/v1/metrics` scrape boundary while removing the direct `prom-client`
|
||||
dependency and lockfile entries.
|
||||
- Changed producer metric recording from a sync callback to an Effect value
|
||||
that runs inside the producer send pipeline.
|
||||
- Added isolated metric-registry tests for producer and consumer Prometheus
|
||||
formatting.
|
||||
- Verification:
|
||||
- `cd ts && bun run check:tsgo`
|
||||
- `cd ts/packages/base && bunx --bun vitest run src/__tests__/metrics-effect.test.ts src/__tests__/producer.test.ts src/__tests__/messaging-runtime.test.ts`
|
||||
- `cd ts/packages/base && bun run build`
|
||||
- `cd ts/packages/base && bun run test`
|
||||
- `cd ts/packages/flow && bun run build`
|
||||
- `cd ts/packages/flow && bunx --bun vitest run src/__tests__/gateway-dispatcher.test.ts`
|
||||
- `cd ts && bun run check`
|
||||
- `cd ts && bun run build`
|
||||
- `cd ts && bun run test`
|
||||
- `cd ts && bun run lint`
|
||||
- `git diff --check`
|
||||
|
||||
## Subagent Findings To Preserve
|
||||
|
||||
- MCP/workbench:
|
||||
|
|
@ -1501,6 +1528,12 @@ Notes:
|
|||
and typed producer/requestor spec-object accessors. New service handlers
|
||||
should hoist spec objects and use those accessors; bare string accessors
|
||||
remain compatibility escapes.
|
||||
- Base metrics are now Effect-native and Prometheus-formatted through
|
||||
`PrometheusMetrics.format`; do not reopen `prom-client` unless a future
|
||||
scrape requirement cannot be represented by Effect metrics.
|
||||
- Numeric public timeout fields such as `timeoutMs` remain compatibility
|
||||
surfaces. Internal runtime config with `Config.number(...Ms)` is still a
|
||||
valid `Config.duration` / `Duration` cleanup target.
|
||||
- Gateway/client:
|
||||
- `EffectRpcClient` now owns its socket/RPC layer with `ManagedRuntime`.
|
||||
Socket errors/JSON parsing now use tagged errors and Schema decoding.
|
||||
|
|
@ -1550,6 +1583,16 @@ Notes:
|
|||
- Shared text-completion stream iteration and the Mistral content assertion are
|
||||
complete. The remaining provider-layer item is parity-backed Effect AI
|
||||
adapter work, not a direct SDK swap.
|
||||
- Scratch-note follow-ups:
|
||||
- `Term` / compact client term serialization is the next strongest schema
|
||||
migration: prefer `S.toTaggedUnion(...).match` or `Match` helpers over the
|
||||
current native switches and unsafe serializer fallbacks.
|
||||
- FlowManager and sibling service `() => Effect.gen(...)` factories remain a
|
||||
broad mechanical `Effect.fn` / `Effect.fnUntraced` cleanup, best handled
|
||||
after the term schema slice.
|
||||
- Long-lived `Map` / `Set` state in ref-backed services can move toward
|
||||
Effect collections later; static lookup tables and local pure traversal
|
||||
maps/sets remain no-ops.
|
||||
|
||||
## Ranked Findings
|
||||
|
||||
|
|
@ -1619,6 +1662,42 @@ Notes:
|
|||
mapping, missing-token config failures, and OpenAI-compatible local-server
|
||||
behavior.
|
||||
|
||||
### No-op: Base Metrics Prometheus Wrapper
|
||||
|
||||
- Status:
|
||||
- Closed as a scratch-note migration target by the Effect Metrics Prometheus
|
||||
slice.
|
||||
- TrustGraph evidence:
|
||||
- `ts/packages/base/src/metrics/prometheus.ts`
|
||||
- `ts/packages/flow/src/gateway/server.ts`
|
||||
- Effect primitives:
|
||||
- `Metric.counter`, `Metric.histogram`, and
|
||||
`effect/unstable/observability` `PrometheusMetrics.format`.
|
||||
- Rule:
|
||||
- Keep the gateway Fastify route as the external scrape boundary, but record
|
||||
TrustGraph metrics through Effect `Metric` values.
|
||||
- Use a fresh `Metric.MetricRegistry` in tests that assert exact scrape
|
||||
content.
|
||||
|
||||
### P1: Term And ClientTerm Tagged-Union Normalization
|
||||
|
||||
- TrustGraph evidence:
|
||||
- `ts/packages/base/src/schema/primitives.ts`
|
||||
- `ts/packages/flow/src/gateway/dispatch/serialize.ts`
|
||||
- `ts/packages/client/src/socket/trustgraph-socket.ts`
|
||||
- Effect primitives:
|
||||
- `S.toTaggedUnion(...).match` and `effect/Match` discriminator helpers.
|
||||
- Rewrite shape:
|
||||
- Add tagged-union helpers for internal `Term` and compact client terms.
|
||||
- Replace serializer native switches with tagged-union matching or
|
||||
`Match.discriminatorsExhaustive`.
|
||||
- Remove unsafe default pass-through casts while preserving compact `g`
|
||||
string compatibility.
|
||||
- Tests:
|
||||
- Extend base schema tests for recursive terms and add gateway serializer
|
||||
coverage for all variants, nested triples, compact graph strings, and
|
||||
malformed client triples.
|
||||
|
||||
### P2: Canonicalize MCP Around The Effect Server
|
||||
|
||||
- Status:
|
||||
|
|
@ -1655,6 +1734,9 @@ Notes:
|
|||
## Recommended PR Order
|
||||
|
||||
1. MCP Effect stdio parity and canonicalization.
|
||||
2. Term/ClientTerm Schema tagged-union and Match normalization.
|
||||
3. FlowManager/service `Effect.fn` normalization.
|
||||
4. Messaging runtime `Config.duration` / `Duration` cleanup.
|
||||
|
||||
## No-Op Rules
|
||||
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@
|
|||
"@effect/opentelemetry": "4.0.0-beta.75",
|
||||
"@effect/platform-browser": "4.0.0-beta.75",
|
||||
"@effect/platform-bun": "4.0.0-beta.75",
|
||||
"effect": "4.0.0-beta.75",
|
||||
"nats": "^2.29.0",
|
||||
"prom-client": "^15.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.75",
|
||||
|
|
@ -631,8 +631,6 @@
|
|||
|
||||
"bezier-js": ["bezier-js@6.1.4", "", {}, "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg=="],
|
||||
|
||||
"bintrees": ["bintrees@1.0.2", "", {}, "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "3.1.2", "content-type": "1.0.5", "debug": "4.4.3", "http-errors": "2.0.1", "iconv-lite": "0.7.2", "on-finished": "2.4.1", "qs": "6.15.0", "raw-body": "3.0.2", "type-is": "2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "2.10.15", "caniuse-lite": "1.0.30001785", "electron-to-chromium": "1.5.331", "node-releases": "2.0.37", "update-browserslist-db": "1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
|
@ -1115,8 +1113,6 @@
|
|||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "tdigest": "0.1.2" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "1.4.0", "object-assign": "4.1.1", "react-is": "16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
|
@ -1245,8 +1241,6 @@
|
|||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@
|
|||
"@effect/opentelemetry": "4.0.0-beta.75",
|
||||
"@effect/platform-browser": "4.0.0-beta.75",
|
||||
"@effect/platform-bun": "4.0.0-beta.75",
|
||||
"nats": "^2.29.0",
|
||||
"prom-client": "^15.1.0"
|
||||
"effect": "4.0.0-beta.75",
|
||||
"nats": "^2.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.75",
|
||||
|
|
|
|||
56
ts/packages/base/src/__tests__/metrics-effect.test.ts
Normal file
56
ts/packages/base/src/__tests__/metrics-effect.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { Effect, Metric } from "effect";
|
||||
import {
|
||||
formatPrometheusMetrics,
|
||||
makeConsumerMetrics,
|
||||
makeProducerMetrics,
|
||||
} from "../metrics/index.js";
|
||||
|
||||
const withFreshMetrics = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.provideService(Metric.MetricRegistry, new Map()));
|
||||
|
||||
describe("Effect metrics", () => {
|
||||
it.effect(
|
||||
"formats producer metrics through Effect Prometheus exporter",
|
||||
Effect.fnUntraced(function* () {
|
||||
const output = yield* withFreshMetrics(
|
||||
Effect.gen(function* () {
|
||||
const metrics = makeProducerMetrics("processor-a", "flow-a", "producer-a");
|
||||
yield* metrics.inc;
|
||||
yield* metrics.inc;
|
||||
return yield* formatPrometheusMetrics;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toContain("# HELP tg_producer_items_total Producer items sent");
|
||||
expect(output).toContain("# TYPE tg_producer_items_total counter");
|
||||
expect(output).toContain('processor="processor-a"');
|
||||
expect(output).toContain('flow="flow-a"');
|
||||
expect(output).toContain('name="producer-a"');
|
||||
expect(output).toMatch(/tg_producer_items_total\{[^}]*\} 2/);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"formats consumer metric timers and counters",
|
||||
Effect.fnUntraced(function* () {
|
||||
const output = yield* withFreshMetrics(
|
||||
Effect.gen(function* () {
|
||||
const metrics = makeConsumerMetrics("processor-a", "flow-a", "consumer-a");
|
||||
yield* metrics.recordTime(1.25);
|
||||
yield* metrics.process("success");
|
||||
yield* metrics.process("error");
|
||||
yield* metrics.rateLimit;
|
||||
return yield* formatPrometheusMetrics;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toContain("# TYPE tg_consumer_request_duration_seconds histogram");
|
||||
expect(output).toMatch(/tg_consumer_request_duration_seconds_count\{[^}]*\} 1/);
|
||||
expect(output).toMatch(/tg_consumer_request_duration_seconds_sum\{[^}]*\} 1.25/);
|
||||
expect(output).toMatch(/tg_consumer_processing_total\{[^}]*status="success"[^}]*\} 1/);
|
||||
expect(output).toMatch(/tg_consumer_processing_total\{[^}]*status="error"[^}]*\} 1/);
|
||||
expect(output).toMatch(/tg_consumer_rate_limit_total\{[^}]*\} 1/);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -41,7 +41,7 @@ import {
|
|||
type MessagingTimeoutError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
import type { ProducerMetrics } from "../metrics/prometheus.js";
|
||||
import type { ProducerMetrics } from "../metrics/index.js";
|
||||
import type { FlowContext } from "./consumer.js";
|
||||
import type { Flow } from "../processor/flow.js";
|
||||
import type { SpecRuntimeRequirements } from "../spec/types.js";
|
||||
|
|
@ -169,9 +169,7 @@ export function makeEffectProducerHandle<T>(
|
|||
Effect.tap(() =>
|
||||
options.metrics === undefined
|
||||
? Effect.void
|
||||
: Effect.sync(() => {
|
||||
options.metrics?.inc();
|
||||
}),
|
||||
: options.metrics.inc,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
export {
|
||||
formatPrometheusMetrics,
|
||||
makeConsumerMetrics,
|
||||
makeProducerMetrics,
|
||||
registry,
|
||||
prometheusContentType,
|
||||
type ConsumerMetrics,
|
||||
type ProducerMetrics,
|
||||
} from "./prometheus.js";
|
||||
|
|
|
|||
|
|
@ -1,18 +1,38 @@
|
|||
/**
|
||||
* Prometheus metrics wrappers.
|
||||
* Effect-native metrics and Prometheus formatting helpers.
|
||||
*
|
||||
* Python reference: trustgraph-base/trustgraph/base/metrics.py
|
||||
*/
|
||||
|
||||
import { Counter, Histogram, Registry, collectDefaultMetrics } from "prom-client";
|
||||
import { Effect, Metric } from "effect";
|
||||
import { PrometheusMetrics } from "effect/unstable/observability";
|
||||
|
||||
export const registry = new Registry();
|
||||
collectDefaultMetrics({ register: registry });
|
||||
export const prometheusContentType = "text/plain; version=0.0.4; charset=utf-8";
|
||||
|
||||
const consumerRequestDuration = Metric.histogram("tg_consumer_request_duration_seconds", {
|
||||
description: "Consumer request processing time",
|
||||
boundaries: Metric.exponentialBoundaries({ start: 0.005, factor: 2, count: 12 }),
|
||||
});
|
||||
|
||||
const consumerProcessing = Metric.counter("tg_consumer_processing_total", {
|
||||
description: "Consumer processing outcomes",
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
const consumerRateLimit = Metric.counter("tg_consumer_rate_limit_total", {
|
||||
description: "Consumer rate limit events",
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
const producerItems = Metric.counter("tg_producer_items_total", {
|
||||
description: "Producer items sent",
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
export interface ConsumerMetrics {
|
||||
readonly recordTime: (seconds: number) => void;
|
||||
readonly process: (status: "success" | "error") => void;
|
||||
readonly rateLimit: () => void;
|
||||
readonly recordTime: (seconds: number) => Effect.Effect<void>;
|
||||
readonly process: (status: "success" | "error") => Effect.Effect<void>;
|
||||
readonly rateLimit: Effect.Effect<void>;
|
||||
}
|
||||
|
||||
export function makeConsumerMetrics(
|
||||
|
|
@ -21,36 +41,22 @@ export function makeConsumerMetrics(
|
|||
name: string,
|
||||
): ConsumerMetrics {
|
||||
const labels = { processor, flow, name };
|
||||
const requestHistogram = new Histogram({
|
||||
name: "tg_consumer_request_duration_seconds",
|
||||
help: "Consumer request processing time",
|
||||
labelNames: ["processor", "flow", "name"],
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
const processingCounter = new Counter({
|
||||
name: "tg_consumer_processing_total",
|
||||
help: "Consumer processing outcomes",
|
||||
labelNames: ["processor", "flow", "name", "status"],
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
const rateLimitCounter = new Counter({
|
||||
name: "tg_consumer_rate_limit_total",
|
||||
help: "Consumer rate limit events",
|
||||
labelNames: ["processor", "flow", "name"],
|
||||
registers: [registry],
|
||||
});
|
||||
const requestHistogram = Metric.withAttributes(consumerRequestDuration, labels);
|
||||
const rateLimitCounter = Metric.withAttributes(consumerRateLimit, labels);
|
||||
|
||||
return {
|
||||
recordTime: (seconds) => requestHistogram.observe(labels, seconds),
|
||||
process: (status) => processingCounter.inc({ ...labels, status }),
|
||||
rateLimit: () => rateLimitCounter.inc(labels),
|
||||
recordTime: (seconds) => Metric.update(requestHistogram, seconds),
|
||||
process: (status) =>
|
||||
Metric.update(
|
||||
Metric.withAttributes(consumerProcessing, { ...labels, status }),
|
||||
1,
|
||||
),
|
||||
rateLimit: Metric.update(rateLimitCounter, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProducerMetrics {
|
||||
readonly inc: () => void;
|
||||
readonly inc: Effect.Effect<void>;
|
||||
}
|
||||
|
||||
export function makeProducerMetrics(
|
||||
|
|
@ -59,14 +65,11 @@ export function makeProducerMetrics(
|
|||
name: string,
|
||||
): ProducerMetrics {
|
||||
const labels = { processor, flow, name };
|
||||
const counter = new Counter({
|
||||
name: "tg_producer_items_total",
|
||||
help: "Producer items sent",
|
||||
labelNames: ["processor", "flow", "name"],
|
||||
registers: [registry],
|
||||
});
|
||||
const counter = Metric.withAttributes(producerItems, labels);
|
||||
|
||||
return {
|
||||
inc: () => counter.inc(labels),
|
||||
inc: Metric.update(counter, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export const formatPrometheusMetrics = PrometheusMetrics.format();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,14 @@ import { Cause, Clock, Config, Effect, Exit, Layer, ManagedRuntime, Random, Scop
|
|||
import * as O from "effect/Option";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as EffectSocket from "effect/unstable/socket/Socket";
|
||||
import { messagingLifecycleError, optionalStringConfig, registry, toTgError, type PubSubBackend } from "@trustgraph/base";
|
||||
import {
|
||||
formatPrometheusMetrics,
|
||||
messagingLifecycleError,
|
||||
optionalStringConfig,
|
||||
prometheusContentType,
|
||||
toTgError,
|
||||
type PubSubBackend,
|
||||
} from "@trustgraph/base";
|
||||
import { makeDispatcherManager } from "./dispatch/manager.js";
|
||||
import { makeGatewayRpcServer } from "./rpc-server.js";
|
||||
|
||||
|
|
@ -227,10 +234,10 @@ export function createGateway(config: GatewayConfig) {
|
|||
);
|
||||
});
|
||||
|
||||
// Metrics endpoint — returns Prometheus metrics from prom-client
|
||||
// Metrics endpoint — returns Effect metrics in Prometheus exposition format.
|
||||
app.get("/api/v1/metrics", (_, reply) => {
|
||||
reply.header("content-type", registry.contentType);
|
||||
return registry.metrics();
|
||||
reply.header("content-type", prometheusContentType);
|
||||
return Effect.runPromise(formatPrometheusMetrics);
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue