@powerhousedao/ph-clint-observability 0.1.0-dev.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @powerhousedao/ph-clint-observability
2
+
3
+ OpenTelemetry + Sentry observability plugin for ph-clint CLIs.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @powerhousedao/ph-clint-observability
9
+ ```
10
+
11
+ Then in your CLI's `defineCli` call:
12
+
13
+ ```ts
14
+ import { defineCli } from '@powerhousedao/ph-clint';
15
+ import { observability } from '@powerhousedao/ph-clint-observability';
16
+
17
+ defineCli({
18
+ // ...
19
+ lifecycle: [observability()],
20
+ });
21
+ ```
22
+
23
+ ## Configuration
24
+
25
+ Two env vars (auto-derived from the framework's `{CLINAME}_{FIELD_NAME}` convention):
26
+
27
+ - `{CLINAME}_SENTRY_DSN` — Sentry DSN. If unset, Sentry is not initialized.
28
+ - `{CLINAME}_OTEL_EXPORTER_OTLP_ENDPOINT` — OTLP HTTP base endpoint for traces and metrics. If unset, OTel is not initialized.
29
+
30
+ When neither is set, the plugin contributes identity wraps (zero overhead) and writes one log line on startup.
31
+
32
+ ## Runtime opt-in
33
+
34
+ When at least one destination is configured, the plugin prompts the end-user on first run for telemetry consent. Consent is persisted to `~/.ph/<cliname>/observability-consent.json`. Non-interactive runs default to denied.
35
+
36
+ To revoke or re-prompt, delete or edit that file:
37
+
38
+ ```jsonc
39
+ { "consent": "denied", "promptedAt": "..." }
40
+ ```
41
+
42
+ ## Local development receiver
43
+
44
+ ```sh
45
+ pnpm telemetry:dev
46
+ ```
47
+
48
+ Runs a small OTLP HTTP receiver on `127.0.0.1:4318` and announces the env var to set so your CLI sends telemetry there. Incoming spans and metrics are pretty-printed to stdout.
49
+
50
+ ## What's instrumented
51
+
52
+ - `framework.bootstrap` — retroactive span covering the pre-config boot window (using `LifecycleInitContext.bootTimings`).
53
+ - `command.execute` — span + `clint.command.executions` counter (result: success|error).
54
+ - `agent.stream` + child `llm.call` — span + token-usage attributes + `clint.agent.stream.duration` histogram.
55
+ - `tool.execute` — span + `clint.tool.executions` counter.
56
+ - `routine.iteration` — span + `clint.routine.iterations` counter.
57
+
58
+ No auto-instrumentation; no monkey-patching of Node stdlib. The framework's own seams provide all visibility.
@@ -0,0 +1,34 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ export type ConsentValue = 'unknown' | 'granted' | 'denied';
3
+ export interface ConsentRecord {
4
+ consent: ConsentValue;
5
+ promptedAt: string | null;
6
+ }
7
+ /**
8
+ * Read the consent record. Returns `{ consent: 'unknown', promptedAt: null }`
9
+ * when the file doesn't exist or is malformed (we treat ambiguity as
10
+ * not-yet-asked rather than denied, so a corrupted file re-prompts on next
11
+ * interactive run).
12
+ */
13
+ export declare function readConsent(userStoreFolder: string): Promise<ConsentRecord>;
14
+ export declare function writeConsent(userStoreFolder: string, record: ConsentRecord): Promise<void>;
15
+ /**
16
+ * Truncate a Sentry DSN to host+project for display. A DSN looks like
17
+ * `https://<publicKey>@<host>/<projectId>`. Stripping the public key avoids
18
+ * leaking secret-shaped data into the prompt text.
19
+ */
20
+ export declare function safeDsnDisplay(dsn: string): string;
21
+ export interface PromptOptions {
22
+ cliName: string;
23
+ sentryDsn?: string;
24
+ otelEndpoint?: string;
25
+ input?: Readable;
26
+ output?: Writable;
27
+ }
28
+ /**
29
+ * Ask the end-user whether to enable telemetry. Returns 'granted' or 'denied'.
30
+ *
31
+ * Default answer (empty input or anything not starting with y/Y) is 'denied' —
32
+ * opt-in stance per privacy norms.
33
+ */
34
+ export declare function promptForConsent(opts: PromptOptions): Promise<ConsentValue>;
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ const FILENAME = 'observability-consent.json';
5
+ /**
6
+ * Read the consent record. Returns `{ consent: 'unknown', promptedAt: null }`
7
+ * when the file doesn't exist or is malformed (we treat ambiguity as
8
+ * not-yet-asked rather than denied, so a corrupted file re-prompts on next
9
+ * interactive run).
10
+ */
11
+ export async function readConsent(userStoreFolder) {
12
+ const filePath = path.join(userStoreFolder, FILENAME);
13
+ try {
14
+ const raw = await fs.readFile(filePath, 'utf-8');
15
+ const parsed = JSON.parse(raw);
16
+ if (parsed.consent === 'granted' || parsed.consent === 'denied' || parsed.consent === 'unknown') {
17
+ return {
18
+ consent: parsed.consent,
19
+ promptedAt: typeof parsed.promptedAt === 'string' ? parsed.promptedAt : null,
20
+ };
21
+ }
22
+ }
23
+ catch {
24
+ // ENOENT or malformed JSON → fall through to unknown
25
+ }
26
+ return { consent: 'unknown', promptedAt: null };
27
+ }
28
+ export async function writeConsent(userStoreFolder, record) {
29
+ await fs.mkdir(userStoreFolder, { recursive: true });
30
+ const filePath = path.join(userStoreFolder, FILENAME);
31
+ await fs.writeFile(filePath, JSON.stringify(record, null, 2) + '\n', 'utf-8');
32
+ }
33
+ /**
34
+ * Truncate a Sentry DSN to host+project for display. A DSN looks like
35
+ * `https://<publicKey>@<host>/<projectId>`. Stripping the public key avoids
36
+ * leaking secret-shaped data into the prompt text.
37
+ */
38
+ export function safeDsnDisplay(dsn) {
39
+ try {
40
+ const u = new URL(dsn);
41
+ return `${u.protocol}//${u.host}${u.pathname}`;
42
+ }
43
+ catch {
44
+ return '(invalid DSN)';
45
+ }
46
+ }
47
+ /**
48
+ * Ask the end-user whether to enable telemetry. Returns 'granted' or 'denied'.
49
+ *
50
+ * Default answer (empty input or anything not starting with y/Y) is 'denied' —
51
+ * opt-in stance per privacy norms.
52
+ */
53
+ export async function promptForConsent(opts) {
54
+ const rl = readline.createInterface({
55
+ input: opts.input ?? process.stdin,
56
+ output: opts.output ?? process.stdout,
57
+ });
58
+ try {
59
+ const destinations = [];
60
+ if (opts.sentryDsn)
61
+ destinations.push(` • Sentry → ${safeDsnDisplay(opts.sentryDsn)}`);
62
+ if (opts.otelEndpoint)
63
+ destinations.push(` • OpenTelemetry → ${opts.otelEndpoint}`);
64
+ const lines = [
65
+ '',
66
+ `${opts.cliName} can share observability data with the following destinations:`,
67
+ ...destinations,
68
+ '',
69
+ ].join('\n');
70
+ (opts.output ?? process.stdout).write(lines);
71
+ const answer = (await rl.question('Enable telemetry? (y/N) ')).trim();
72
+ return /^y(es)?$/i.test(answer) ? 'granted' : 'denied';
73
+ }
74
+ finally {
75
+ rl.close();
76
+ }
77
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ interface CliArgs {
3
+ cliName: string;
4
+ port: number;
5
+ host: string;
6
+ }
7
+ export declare function parseCliArgs(argv: string[]): CliArgs;
8
+ export declare function envPrefix(cliName: string): string;
9
+ export interface DevServerOptions {
10
+ cliName: string;
11
+ port: number;
12
+ host: string;
13
+ /** Where to write logs. Defaults to process.stdout. */
14
+ out?: NodeJS.WritableStream;
15
+ }
16
+ export interface DevServerHandle {
17
+ url: string;
18
+ port: number;
19
+ close: () => Promise<void>;
20
+ }
21
+ /**
22
+ * Start the receiver and return a handle for tests / programmatic use. The
23
+ * server listens until `close()` is called or the process exits.
24
+ */
25
+ export declare function startDevServer(opts: DevServerOptions): Promise<DevServerHandle>;
26
+ export {};
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ph-telemetry-dev — local OTLP HTTP receiver for dev work.
4
+ *
5
+ * Inspired by the deleted service-announcer pattern: prints the endpoint to
6
+ * stdout so the dev knows what env var to set in another terminal before
7
+ * running their CLI.
8
+ *
9
+ * Receives OTel OTLP HTTP at /v1/traces and /v1/metrics. JSON payloads
10
+ * (OTEL_EXPORTER_OTLP_PROTOCOL=http/json on the sender side) are
11
+ * pretty-printed; protobuf payloads show byte length only.
12
+ */
13
+ import http from 'node:http';
14
+ import { parseArgs } from 'node:util';
15
+ export function parseCliArgs(argv) {
16
+ const { values } = parseArgs({
17
+ args: argv.slice(2),
18
+ options: {
19
+ 'cli-name': { type: 'string' },
20
+ port: { type: 'string', default: '4318' },
21
+ host: { type: 'string', default: '127.0.0.1' },
22
+ },
23
+ });
24
+ return {
25
+ cliName: values['cli-name'] ?? 'mycli',
26
+ port: Number(values.port),
27
+ host: values.host ?? '127.0.0.1',
28
+ };
29
+ }
30
+ export function envPrefix(cliName) {
31
+ return cliName.toUpperCase().replace(/-/g, '_');
32
+ }
33
+ /**
34
+ * Start the receiver and return a handle for tests / programmatic use. The
35
+ * server listens until `close()` is called or the process exits.
36
+ */
37
+ export async function startDevServer(opts) {
38
+ const out = opts.out ?? process.stdout;
39
+ const server = http.createServer((req, res) => {
40
+ if (req.url === '/v1/traces' || req.url === '/v1/metrics') {
41
+ const chunks = [];
42
+ req.on('data', (c) => chunks.push(c));
43
+ req.on('end', () => {
44
+ const body = Buffer.concat(chunks);
45
+ const kind = req.url === '/v1/traces' ? 'TRACE' : 'METRIC';
46
+ try {
47
+ const json = JSON.parse(body.toString('utf-8'));
48
+ out.write(`[${kind}] ${JSON.stringify(json, null, 2)}\n`);
49
+ }
50
+ catch {
51
+ out.write(`[${kind}] ${body.length} bytes (protobuf — set OTEL_EXPORTER_OTLP_PROTOCOL=http/json for readable output)\n`);
52
+ }
53
+ res.writeHead(200, { 'Content-Type': 'application/x-protobuf' });
54
+ res.end();
55
+ });
56
+ return;
57
+ }
58
+ res.writeHead(404);
59
+ res.end();
60
+ });
61
+ await new Promise((resolve, reject) => {
62
+ server.once('error', reject);
63
+ server.listen(opts.port, opts.host, () => resolve());
64
+ });
65
+ const addr = server.address();
66
+ const actualPort = typeof addr === 'object' && addr ? addr.port : opts.port;
67
+ const url = `http://${opts.host}:${actualPort}`;
68
+ const upper = envPrefix(opts.cliName);
69
+ out.write(`\nph-telemetry-dev — local OTLP receiver listening on ${url}\n\n`);
70
+ out.write('Point your CLI at this receiver by exporting:\n');
71
+ out.write(` ${upper}_OTEL_EXPORTER_OTLP_ENDPOINT=${url}\n\n`);
72
+ out.write('Optional (for readable trace payloads):\n');
73
+ out.write(' OTEL_EXPORTER_OTLP_PROTOCOL=http/json\n\n');
74
+ return {
75
+ url,
76
+ port: actualPort,
77
+ close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())),
78
+ };
79
+ }
80
+ /* istanbul ignore next -- entry-point bootstrap; exercised by manual `pnpm telemetry:dev` */
81
+ async function main() {
82
+ const args = parseCliArgs(process.argv);
83
+ const handle = await startDevServer(args);
84
+ process.once('SIGINT', () => { void handle.close().then(() => process.exit(0)); });
85
+ process.once('SIGTERM', () => { void handle.close().then(() => process.exit(0)); });
86
+ }
87
+ // Only run main when invoked as a script (not when imported by tests).
88
+ /* istanbul ignore next */
89
+ if (import.meta.url === `file://${process.argv[1]}`) {
90
+ main().catch((err) => {
91
+ process.stderr.write(`ph-telemetry-dev: ${err instanceof Error ? err.message : String(err)}\n`);
92
+ process.exit(1);
93
+ });
94
+ }
@@ -0,0 +1,3 @@
1
+ export { observability, observabilityConfigSchema } from './plugin.js';
2
+ export type { ObservabilityOptions, ObservabilityConfig } from './plugin.js';
3
+ export type { ConsentValue, ConsentRecord } from './consent.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { observability, observabilityConfigSchema } from './plugin.js';
package/dist/otel.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { Tracer, Meter } from '@opentelemetry/api';
2
+ export interface OtelInitInput {
3
+ endpoint: string;
4
+ serviceName: string;
5
+ version: string;
6
+ sentryEnabled: boolean;
7
+ }
8
+ export interface OtelHandle {
9
+ tracer: Tracer;
10
+ meter: Meter;
11
+ shutdown: () => Promise<void>;
12
+ }
13
+ /**
14
+ * Dynamic-imported OpenTelemetry NodeSDK init. No auto-instrumentation:
15
+ * the framework's wrap registry provides all instrumentation points, so
16
+ * there is zero monkey-patching of Node stdlib. The SDK loads, sets up
17
+ * BatchSpanProcessor + PeriodicExportingMetricReader against the configured
18
+ * OTLP HTTP endpoint, and stops.
19
+ *
20
+ * If Sentry is also enabled, SentrySpanProcessor is added so spans flow to
21
+ * both Tempo/OTLP backend AND Sentry — single source, two sinks, matching
22
+ * trace IDs.
23
+ */
24
+ export declare function initOtel(opts: OtelInitInput): Promise<OtelHandle>;
package/dist/otel.js ADDED
@@ -0,0 +1,57 @@
1
+ function stripTrailingSlash(s) {
2
+ return s.replace(/\/$/, '');
3
+ }
4
+ /**
5
+ * Dynamic-imported OpenTelemetry NodeSDK init. No auto-instrumentation:
6
+ * the framework's wrap registry provides all instrumentation points, so
7
+ * there is zero monkey-patching of Node stdlib. The SDK loads, sets up
8
+ * BatchSpanProcessor + PeriodicExportingMetricReader against the configured
9
+ * OTLP HTTP endpoint, and stops.
10
+ *
11
+ * If Sentry is also enabled, SentrySpanProcessor is added so spans flow to
12
+ * both Tempo/OTLP backend AND Sentry — single source, two sinks, matching
13
+ * trace IDs.
14
+ */
15
+ export async function initOtel(opts) {
16
+ const [{ NodeSDK }, { OTLPTraceExporter }, { OTLPMetricExporter }, sdkMetrics, sdkTraceBase, resourcesMod, semconv, apiMod,] = await Promise.all([
17
+ import('@opentelemetry/sdk-node'),
18
+ import('@opentelemetry/exporter-trace-otlp-http'),
19
+ import('@opentelemetry/exporter-metrics-otlp-http'),
20
+ import('@opentelemetry/sdk-metrics'),
21
+ import('@opentelemetry/sdk-trace-base'),
22
+ import('@opentelemetry/resources'),
23
+ import('@opentelemetry/semantic-conventions'),
24
+ import('@opentelemetry/api'),
25
+ ]);
26
+ const base = stripTrailingSlash(opts.endpoint);
27
+ const resource = resourcesMod.resourceFromAttributes({
28
+ [semconv.ATTR_SERVICE_NAME]: opts.serviceName,
29
+ [semconv.ATTR_SERVICE_VERSION]: opts.version,
30
+ });
31
+ const spanProcessors = [
32
+ new sdkTraceBase.BatchSpanProcessor(new OTLPTraceExporter({ url: `${base}/v1/traces` })),
33
+ ];
34
+ if (opts.sentryEnabled) {
35
+ const { SentrySpanProcessor } = await import('@sentry/opentelemetry');
36
+ // SentrySpanProcessor extends SpanProcessor but its declared type from
37
+ // @sentry/opentelemetry can drift from the host @opentelemetry/sdk-trace-base
38
+ // version. Cast is intentional — confirmed working at runtime.
39
+ spanProcessors.push(new SentrySpanProcessor());
40
+ }
41
+ const metricReader = new sdkMetrics.PeriodicExportingMetricReader({
42
+ exporter: new OTLPMetricExporter({ url: `${base}/v1/metrics` }),
43
+ exportIntervalMillis: 5000,
44
+ });
45
+ const sdk = new NodeSDK({
46
+ resource,
47
+ spanProcessors,
48
+ metricReader,
49
+ // instrumentations: intentionally NOT specified — no auto-instrumentation.
50
+ });
51
+ sdk.start();
52
+ return {
53
+ tracer: apiMod.trace.getTracer(opts.serviceName, opts.version),
54
+ meter: apiMod.metrics.getMeter(opts.serviceName, opts.version),
55
+ shutdown: () => sdk.shutdown(),
56
+ };
57
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import type { LifecycleHook } from '@powerhousedao/ph-clint';
3
+ import { type ConsentValue } from './consent.js';
4
+ export interface ObservabilityOptions {
5
+ /**
6
+ * Override the consent prompter — primarily a test hook. Returns the
7
+ * desired consent value (granted/denied). When omitted, the plugin uses
8
+ * `promptForConsent` against `process.stdin` and `process.stdout`.
9
+ */
10
+ promptOverride?: (input: {
11
+ cliName: string;
12
+ sentryDsn?: string;
13
+ otelEndpoint?: string;
14
+ }) => Promise<ConsentValue>;
15
+ }
16
+ export declare const observabilityConfigSchema: z.ZodObject<{
17
+ sentryDsn: z.ZodOptional<z.ZodString>;
18
+ otelExporterOtlpEndpoint: z.ZodOptional<z.ZodString>;
19
+ }, z.core.$strip>;
20
+ export type ObservabilityConfig = z.infer<typeof observabilityConfigSchema>;
21
+ /**
22
+ * The observability LifecycleHook factory. Register with:
23
+ *
24
+ * defineCli({ lifecycle: [observability()] })
25
+ *
26
+ * Reads `sentryDsn` and `otelExporterOtlpEndpoint` from the resolved config
27
+ * (via the merged schema fragment above). When neither is configured, returns
28
+ * no contributions — identity wraps everywhere, zero overhead, one info log.
29
+ *
30
+ * When a destination is configured, asks the end-user for consent on first
31
+ * run (interactive TTY only), persisting the decision to
32
+ * `~/.ph/<cliname>/observability-consent.json`.
33
+ */
34
+ export declare function observability(opts?: ObservabilityOptions): LifecycleHook;
package/dist/plugin.js ADDED
@@ -0,0 +1,92 @@
1
+ import { z } from 'zod';
2
+ import { readConsent, writeConsent, promptForConsent } from './consent.js';
3
+ export const observabilityConfigSchema = z.object({
4
+ sentryDsn: z.string().url().optional()
5
+ .describe('Sentry DSN. If unset, Sentry is not initialized.'),
6
+ otelExporterOtlpEndpoint: z.string().url().optional()
7
+ .describe('OTLP HTTP endpoint for traces and metrics. If unset, OTel is not initialized.'),
8
+ });
9
+ /**
10
+ * The observability LifecycleHook factory. Register with:
11
+ *
12
+ * defineCli({ lifecycle: [observability()] })
13
+ *
14
+ * Reads `sentryDsn` and `otelExporterOtlpEndpoint` from the resolved config
15
+ * (via the merged schema fragment above). When neither is configured, returns
16
+ * no contributions — identity wraps everywhere, zero overhead, one info log.
17
+ *
18
+ * When a destination is configured, asks the end-user for consent on first
19
+ * run (interactive TTY only), persisting the decision to
20
+ * `~/.ph/<cliname>/observability-consent.json`.
21
+ */
22
+ export function observability(opts = {}) {
23
+ return {
24
+ name: 'observability',
25
+ configSchema: observabilityConfigSchema,
26
+ async onInit(ctx) {
27
+ const sentryDsn = ctx.config.sentryDsn;
28
+ const otelEndpoint = ctx.config.otelExporterOtlpEndpoint;
29
+ if (!sentryDsn && !otelEndpoint) {
30
+ ctx.log.info('[observability] No destinations configured. Identity wraps active.');
31
+ return {};
32
+ }
33
+ // ── Consent gate ──────────────────────────────────────────────
34
+ let consent = (await readConsent(ctx.userStoreFolder)).consent;
35
+ if (consent === 'unknown') {
36
+ if (ctx.isInteractive) {
37
+ consent = opts.promptOverride
38
+ ? await opts.promptOverride({ cliName: ctx.cliName, sentryDsn, otelEndpoint })
39
+ : await promptForConsent({ cliName: ctx.cliName, sentryDsn, otelEndpoint });
40
+ await writeConsent(ctx.userStoreFolder, { consent, promptedAt: new Date().toISOString() });
41
+ }
42
+ else {
43
+ // Non-interactive runs default to denied (safer for CI). Persist so
44
+ // subsequent interactive runs can re-prompt by editing the file.
45
+ consent = 'denied';
46
+ await writeConsent(ctx.userStoreFolder, { consent: 'denied', promptedAt: new Date().toISOString() });
47
+ ctx.log.info(`[observability] Telemetry destinations configured but no consent recorded. ` +
48
+ `Defaulting to denied for non-interactive run. Edit ${ctx.userStoreFolder}/observability-consent.json to opt in.`);
49
+ return {};
50
+ }
51
+ }
52
+ if (consent === 'denied') {
53
+ ctx.log.info(`[observability] Telemetry destinations configured but consent denied. ` +
54
+ `Edit ${ctx.userStoreFolder}/observability-consent.json to opt in.`);
55
+ return {};
56
+ }
57
+ // consent === 'granted' — initialize SDKs and contribute wraps.
58
+ const { initSentry } = await import('./sentry.js');
59
+ const { initOtel } = await import('./otel.js');
60
+ const { buildMetricInstruments, buildWraps, emitBootstrapSpan } = await import('./wraps.js');
61
+ const sentry = sentryDsn ? await initSentry({ dsn: sentryDsn, release: ctx.cliVersion }) : null;
62
+ const otel = otelEndpoint
63
+ ? await initOtel({
64
+ endpoint: otelEndpoint,
65
+ serviceName: ctx.cliName,
66
+ version: ctx.cliVersion,
67
+ sentryEnabled: sentry !== null,
68
+ })
69
+ : null;
70
+ const metricInstruments = buildMetricInstruments(otel, ctx.cliName, ctx.cliVersion);
71
+ const wraps = buildWraps(metricInstruments, sentry);
72
+ // Retroactive bootstrap span — only useful when OTel is producing real spans.
73
+ if (otel) {
74
+ try {
75
+ emitBootstrapSpan(otel, ctx.bootTimings);
76
+ }
77
+ catch (err) {
78
+ ctx.log.debug(`[observability] failed to emit bootstrap span: ${err.message}`);
79
+ }
80
+ }
81
+ return {
82
+ contribute: wraps,
83
+ shutdown: async () => {
84
+ if (otel)
85
+ await otel.shutdown();
86
+ if (sentry)
87
+ await sentry.flush();
88
+ },
89
+ };
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,22 @@
1
+ export interface SentryInitInput {
2
+ dsn: string;
3
+ release?: string;
4
+ }
5
+ export interface SentryHandle {
6
+ captureException: (err: unknown) => void;
7
+ flush: (timeoutMs?: number) => Promise<boolean>;
8
+ }
9
+ /**
10
+ * Dynamic-imported Sentry initialization. Loaded only when a SENTRY_DSN-equivalent
11
+ * is configured — keeps the SDK out of memory when telemetry is off.
12
+ *
13
+ * Default integrations and Sentry's own OTel setup are disabled:
14
+ * - defaultIntegrations: false → no console patching, no HTTP breadcrumbs,
15
+ * no Express integration, no global uncaught
16
+ * handlers we didn't ask for.
17
+ * - integrations: [] → explicit empty list.
18
+ * - skipOpenTelemetrySetup: true → our otel.ts owns the tracer + meter
19
+ * providers; SentrySpanProcessor bridges
20
+ * spans to Sentry from there.
21
+ */
22
+ export declare function initSentry(opts: SentryInitInput): Promise<SentryHandle>;
package/dist/sentry.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Dynamic-imported Sentry initialization. Loaded only when a SENTRY_DSN-equivalent
3
+ * is configured — keeps the SDK out of memory when telemetry is off.
4
+ *
5
+ * Default integrations and Sentry's own OTel setup are disabled:
6
+ * - defaultIntegrations: false → no console patching, no HTTP breadcrumbs,
7
+ * no Express integration, no global uncaught
8
+ * handlers we didn't ask for.
9
+ * - integrations: [] → explicit empty list.
10
+ * - skipOpenTelemetrySetup: true → our otel.ts owns the tracer + meter
11
+ * providers; SentrySpanProcessor bridges
12
+ * spans to Sentry from there.
13
+ */
14
+ export async function initSentry(opts) {
15
+ const Sentry = await import('@sentry/node');
16
+ Sentry.init({
17
+ dsn: opts.dsn,
18
+ release: opts.release,
19
+ defaultIntegrations: false,
20
+ integrations: [],
21
+ skipOpenTelemetrySetup: true,
22
+ });
23
+ return {
24
+ captureException: (err) => { Sentry.captureException(err); },
25
+ flush: (timeoutMs = 2000) => Sentry.flush(timeoutMs),
26
+ };
27
+ }
@@ -0,0 +1,24 @@
1
+ import { type Counter, type Histogram } from '@opentelemetry/api';
2
+ import type { BootTimings, WrapRegistry } from '@powerhousedao/ph-clint';
3
+ import type { OtelHandle } from './otel.js';
4
+ import type { SentryHandle } from './sentry.js';
5
+ export interface MetricInstruments {
6
+ llmTokens: Counter;
7
+ toolExecutions: Counter;
8
+ routineIterations: Counter;
9
+ commandExecutions: Counter;
10
+ agentStreamDuration: Histogram;
11
+ }
12
+ export declare function buildMetricInstruments(otel: OtelHandle | null, cliName: string, version: string): MetricInstruments;
13
+ /**
14
+ * Build the four wrap implementations for the framework's WrapRegistry.
15
+ * Returns partial — when a slot is omitted, the framework's composition
16
+ * falls through to identity.
17
+ */
18
+ export declare function buildWraps(metrics: MetricInstruments, _sentry: SentryHandle | null): Partial<WrapRegistry>;
19
+ /**
20
+ * Emit a retroactive `framework.bootstrap` span covering the pre-config boot
21
+ * window. OTel accepts past `startTime` and per-event timestamps so the span
22
+ * lands in the right position on the trace timeline.
23
+ */
24
+ export declare function emitBootstrapSpan(otel: OtelHandle, bootTimings: BootTimings): void;
package/dist/wraps.js ADDED
@@ -0,0 +1,155 @@
1
+ import { context as otelContext, metrics as otelMetrics, trace, SpanStatusCode } from '@opentelemetry/api';
2
+ export function buildMetricInstruments(otel, cliName, version) {
3
+ // getMeter() returns a no-op meter when no MeterProvider has been registered,
4
+ // so this is safe even when OTel is off — the counters/histograms become
5
+ // no-ops too.
6
+ const meter = otel?.meter ?? otelMetrics.getMeter(cliName, version);
7
+ return {
8
+ llmTokens: meter.createCounter('clint.llm.tokens', {
9
+ description: 'LLM token usage by kind (prompt|completion) and model.',
10
+ }),
11
+ toolExecutions: meter.createCounter('clint.tool.executions', {
12
+ description: 'Tool invocations by tool name and result.',
13
+ }),
14
+ routineIterations: meter.createCounter('clint.routine.iterations', {
15
+ description: 'Routine loop iterations.',
16
+ }),
17
+ commandExecutions: meter.createCounter('clint.command.executions', {
18
+ description: 'Command dispatches by command id and result.',
19
+ }),
20
+ agentStreamDuration: meter.createHistogram('clint.agent.stream.duration', {
21
+ description: 'Agent stream() duration in milliseconds.',
22
+ unit: 'ms',
23
+ }),
24
+ };
25
+ }
26
+ /**
27
+ * Build the four wrap implementations for the framework's WrapRegistry.
28
+ * Returns partial — when a slot is omitted, the framework's composition
29
+ * falls through to identity.
30
+ */
31
+ export function buildWraps(metrics, _sentry) {
32
+ const tracer = trace.getTracer('ph-clint');
33
+ const command = async (id, inner) => {
34
+ const span = tracer.startSpan('command.execute', { attributes: { 'command.id': id } });
35
+ const start = Date.now();
36
+ try {
37
+ const result = await inner();
38
+ metrics.commandExecutions.add(1, { command: id, result: 'success' });
39
+ span.setAttribute('command.duration_ms', Date.now() - start);
40
+ return result;
41
+ }
42
+ catch (err) {
43
+ metrics.commandExecutions.add(1, { command: id, result: 'error' });
44
+ span.recordException(err);
45
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
46
+ span.setAttribute('command.duration_ms', Date.now() - start);
47
+ throw err;
48
+ }
49
+ finally {
50
+ span.end();
51
+ }
52
+ };
53
+ const agentStream = (inner, attrs) => {
54
+ return async function* (prompt, opts) {
55
+ const streamSpan = tracer.startSpan('agent.stream', {
56
+ attributes: { 'agent.id': attrs.agentId },
57
+ });
58
+ const llmSpan = tracer.startSpan('llm.call', undefined, trace.setSpan(otelContext.active(), streamSpan));
59
+ const start = Date.now();
60
+ let result = 'success';
61
+ try {
62
+ for await (const chunk of inner(prompt, opts)) {
63
+ const u = chunk;
64
+ if (u && u.usage) {
65
+ const m = u.model ?? 'unknown';
66
+ if (typeof u.usage.promptTokens === 'number') {
67
+ llmSpan.setAttribute('llm.tokens.prompt', u.usage.promptTokens);
68
+ metrics.llmTokens.add(u.usage.promptTokens, { kind: 'prompt', model: m });
69
+ }
70
+ if (typeof u.usage.completionTokens === 'number') {
71
+ llmSpan.setAttribute('llm.tokens.completion', u.usage.completionTokens);
72
+ metrics.llmTokens.add(u.usage.completionTokens, { kind: 'completion', model: m });
73
+ }
74
+ if (typeof u.usage.totalTokens === 'number') {
75
+ llmSpan.setAttribute('llm.tokens.total', u.usage.totalTokens);
76
+ }
77
+ llmSpan.setAttribute('llm.model', m);
78
+ }
79
+ yield chunk;
80
+ }
81
+ }
82
+ catch (err) {
83
+ result = 'error';
84
+ streamSpan.recordException(err);
85
+ streamSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
86
+ llmSpan.recordException(err);
87
+ llmSpan.setStatus({ code: SpanStatusCode.ERROR });
88
+ throw err;
89
+ }
90
+ finally {
91
+ llmSpan.end();
92
+ const duration = Date.now() - start;
93
+ metrics.agentStreamDuration.record(duration, { result, 'agent.id': attrs.agentId });
94
+ streamSpan.end();
95
+ }
96
+ };
97
+ };
98
+ const tool = (name, t) => {
99
+ return {
100
+ ...t,
101
+ execute: async (args) => {
102
+ const span = tracer.startSpan('tool.execute', { attributes: { 'tool.name': name } });
103
+ const start = Date.now();
104
+ try {
105
+ const result = await t.execute(args);
106
+ metrics.toolExecutions.add(1, { tool: name, result: 'success' });
107
+ span.setAttribute('tool.duration_ms', Date.now() - start);
108
+ return result;
109
+ }
110
+ catch (err) {
111
+ metrics.toolExecutions.add(1, { tool: name, result: 'error' });
112
+ span.recordException(err);
113
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
114
+ span.setAttribute('tool.duration_ms', Date.now() - start);
115
+ throw err;
116
+ }
117
+ finally {
118
+ span.end();
119
+ }
120
+ },
121
+ };
122
+ };
123
+ const routineIteration = async (attrs, inner) => {
124
+ const span = tracer.startSpan('routine.iteration', { attributes: { 'routine.index': attrs.index } });
125
+ const start = Date.now();
126
+ try {
127
+ const result = await inner();
128
+ metrics.routineIterations.add(1);
129
+ span.setAttribute('routine.duration_ms', Date.now() - start);
130
+ return result;
131
+ }
132
+ catch (err) {
133
+ span.recordException(err);
134
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
135
+ throw err;
136
+ }
137
+ finally {
138
+ span.end();
139
+ }
140
+ };
141
+ return { command, agentStream, tool, routineIteration };
142
+ }
143
+ /**
144
+ * Emit a retroactive `framework.bootstrap` span covering the pre-config boot
145
+ * window. OTel accepts past `startTime` and per-event timestamps so the span
146
+ * lands in the right position on the trace timeline.
147
+ */
148
+ export function emitBootstrapSpan(otel, bootTimings) {
149
+ const span = otel.tracer.startSpan('framework.bootstrap', {
150
+ startTime: new Date(bootTimings.bootStartedAt),
151
+ });
152
+ span.addEvent('config.resolved', {}, new Date(bootTimings.configResolvedAt));
153
+ span.addEvent('lifecycle.init.started', {}, new Date(bootTimings.lifecycleInitStartedAt));
154
+ span.end();
155
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@powerhousedao/ph-clint-observability",
3
+ "version": "0.1.0-dev.69",
4
+ "type": "module",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "bin": {
17
+ "ph-telemetry-dev": "./dist/dev-server.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "dev": "tsc --watch",
25
+ "test:types": "tsc --noEmit",
26
+ "test": "pnpm test:types && NODE_OPTIONS='--experimental-vm-modules' jest --coverage --maxWorkers=4"
27
+ },
28
+ "dependencies": {
29
+ "@opentelemetry/api": "^1.9.0",
30
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.205.0",
31
+ "@opentelemetry/exporter-trace-otlp-http": "^0.205.0",
32
+ "@opentelemetry/resources": "^2.0.1",
33
+ "@opentelemetry/sdk-metrics": "^2.0.1",
34
+ "@opentelemetry/sdk-node": "^0.205.0",
35
+ "@opentelemetry/sdk-trace-base": "^2.0.1",
36
+ "@opentelemetry/semantic-conventions": "^1.36.0",
37
+ "@sentry/node": "^8.50.0",
38
+ "@sentry/opentelemetry": "^8.50.0",
39
+ "zod": "^4.3.6"
40
+ },
41
+ "peerDependencies": {
42
+ "@powerhousedao/ph-clint": "^0.1.0-dev.69"
43
+ },
44
+ "devDependencies": {
45
+ "@jest/globals": "^30.3.0",
46
+ "@types/node": "^25.5.2",
47
+ "jest": "^30.3.0",
48
+ "ts-jest": "^29.4.9",
49
+ "typescript": "^6.0.2"
50
+ },
51
+ "engines": {
52
+ "node": ">=22.13.0"
53
+ }
54
+ }