diff --git a/docs/app/app.config.ts b/docs/app/app.config.ts index 12f0146e..6872e563 100644 --- a/docs/app/app.config.ts +++ b/docs/app/app.config.ts @@ -111,6 +111,7 @@ export default defineAppConfig({ '/guide/json-render', '/guide/diagnostics', '/guide/streaming', + '/guide/tracing-channels', ], }, { diff --git a/docs/content/1.guide/24.tracing-channels.md b/docs/content/1.guide/24.tracing-channels.md new file mode 100644 index 00000000..4a53244e --- /dev/null +++ b/docs/content/1.guide/24.tracing-channels.md @@ -0,0 +1,107 @@ +--- +title: 'Tracing Channels' +navigation: + icon: i-lucide-activity +description: 'ctx.tracing lists the Node.js Tracing Channels an app publishes, subscribes to them on demand, and folds their lifecycle events into trace records a UI can show.' +--- + +`ctx.tracing` lists the Node.js Tracing Channels an app publishes, subscribes to them on demand, and folds their lifecycle events into trace records a UI can show. + +A Tracing Channel is a named group of five diagnostics channels (`start`, `end`, `asyncStart`, `asyncEnd`, `error`) from [`node:diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel). A library publishes to them around one unit of work. Node itself publishes `module.require` and `net.server.listen`. + +The [Node-Side API reference](/references/node-api#devframetracinghost) collects the host methods as a lookup table. + +## Listing the channels your app publishes + +Node has no API that lists channels, so devframe learns the names from three sources: the two built-in Node channels, `ctx.tracing.register()` calls, and the `tracing.channels` field. + +```ts +import { defineDevframe } from 'devframe' + +export default defineDevframe({ + id: 'my-tool', + name: 'My Tool', + tracing: { + channels: [ + 'my-app:db', + { name: 'my-app:queue', description: 'Job queue dispatch' }, + ], + }, + setup(ctx) {}, +}) +``` + +A hub lists host-level channels the same way with `initHub({ tracing: { channels } })`. Every mounted devframe's own `tracing` field joins the same list, because a hub shares one `ctx.tracing`. + +## Publishing a Tracing Channel + +`register()` returns Node's `TracingChannel`, so one call both lists the channel and gives you the object to trace with: + +```ts +export default defineDevframe({ + id: 'my-tool', + name: 'My Tool', + setup(ctx) { + const queries = ctx.tracing.register('my-tool:query', { description: 'Database queries' }) + + ctx.rpc.register({ + name: 'my-tool:run-query', + type: 'query', + handler: (sql: string) => queries.tracePromise(() => db.run(sql), { sql }), + }) + }, +}) +``` + +`tracePromise` writes the resolved value to `context.result` and a rejection to `context.error`, so a record shows both. Publishing costs nothing while nobody records: `TracingChannel` skips its work when `hasSubscribers` is `false`. + +## Recording + +Recording is off until something calls `record(name)`. Subscribing flips `hasSubscribers` in the app, so the app starts paying the tracing cost only while someone looks. + +```ts +ctx.tracing.record('module.require') +const off = ctx.tracing.onRecord('module.require', (record) => { + console.log(record.status, record.duration, record.context) +}) +// later +off() +ctx.tracing.stop('module.require') +``` + +Every lifecycle event that shares the same context object folds into one record: + +```ts +interface DevframeTraceRecord { + id: string + channel: string + startedAt: number + duration?: number // ms from start to the latest event + status: 'pending' | 'ok' | 'error' + context: SerializedValue + result?: SerializedValue + error?: { name: string, message: string, stack?: string } + events: { phase: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error', at: number }[] +} +``` + +The context, result and error pass through a serializer that caps depth at 4, truncates long strings, drops functions, and marks cycles, so a record that holds a socket or a request object still crosses the wire. A ring buffer keeps the last 500 records per channel; `records(name)` reads them and `clear(name)` drops them. + +## Reading from the browser + +The node side publishes two wire pieces. `devframe:tracing:channels` is a shared state keyed by channel name with `source`, `recording`, `count` and, while recording, a `streamId`. `devframe:tracing` is a streaming channel; subscribe with that `streamId` to receive each record as it updates, and upsert by `id`: + +```ts +const channels = await rpc.sharedState.get('devframe:tracing:channels') +await rpc.call('devframe:tracing:record', 'module.require') +const { streamId } = channels.value()['module.require']! +for await (const record of rpc.streaming.subscribe('devframe:tracing', streamId)) { + records.set(record.id, record) +} +``` + +`record()` and `clear()` issue a fresh `streamId`, so watch the shared state and re-subscribe when it changes. The RPC actions `devframe:tracing:record`, `devframe:tracing:stop` and `devframe:tracing:clear` take the channel name. + +## Runtimes + +Node.js and Bun provide `tracingChannel`. On a runtime without it, every `ctx.tracing` method is a no-op, `register()` returns a stand-in whose `traceSync` / `tracePromise` run the function directly, and the first `record()` reports [`DF0081`](/errors/DF0081) once. diff --git a/docs/content/6.errors/DF0081.md b/docs/content/6.errors/DF0081.md new file mode 100644 index 00000000..da84425f --- /dev/null +++ b/docs/content/6.errors/DF0081.md @@ -0,0 +1,27 @@ +--- +title: 'DF0081: Tracing Channels Unavailable' +description: 'The runtime does not provide `node:diagnostics_channel`''s `tracingChannel`, so `ctx.tracing` records nothing.' +--- + +## Message + +> Tracing Channels are unavailable: this runtime ({runtime}) does not provide `node:diagnostics_channel`'s `tracingChannel`, so `ctx.tracing` records nothing. + +## Cause + +`ctx.tracing` wraps the `TracingChannel` class from `node:diagnostics_channel`. Node.js and Bun provide it. A runtime that ships the module without `tracingChannel` (Deno at the time of writing) cannot subscribe to a Tracing Channel, so every `ctx.tracing` method is a no-op there. The warning fires once per process, the first time something calls `record()`. + +## Example + +```ts +// Running under Deno +ctx.tracing.record('module.require') // ⚠ reports DF0081, records nothing +``` + +## Fix + +Run the dev server under Node.js 22+ or Bun to record Tracing Channels. No code change is needed: `register()` still returns a channel whose `traceSync` / `tracePromise` run the wrapped function directly, so producers keep working. + +## Source + +- [`packages/devframe/src/node/host-tracing.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-tracing.ts): `DevframeTracingHostImpl.record()` reports this once when the module loaded at construction has no `tracingChannel` function. diff --git a/docs/content/8.references/1.terms.md b/docs/content/8.references/1.terms.md index d53d5703..0b12b14b 100644 --- a/docs/content/8.references/1.terms.md +++ b/docs/content/8.references/1.terms.md @@ -35,6 +35,8 @@ A devframe has two halves: the **node side** registers RPC functions and owns st | **workspace scope** | Committable per-repository storage. | `DevframeStorageScope` | | **project scope** | Per-checkout storage, gitignored. | `DevframeStorageScope` | | **global scope** | Per-user storage. | `DevframeStorageScope` | +| **Tracing Channel** | A named group of five Node.js diagnostics channels (`start`, `end`, `asyncStart`, `asyncEnd`, `error`) that a library publishes around one unit of work. `ctx.tracing` lists them and records them on demand. | `ctx.tracing`, `node:diagnostics_channel` | +| **trace record** | One traced unit of work: every Tracing Channel event that shared the same context object, folded into one row with status, duration, context, result and error. | `DevframeTraceRecord` | ## Browser side diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 96fb817f..e696deff 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -112,6 +112,15 @@ Pushed to subscribed RPC clients, wired by the core node side. | `devframe:streaming:end` | A streaming terminator (optionally an error). | | `devframe:streaming:upload-cancel` | Server-side cancel of an in-flight upload. | +### Shared state & streams (server → client) + +Published by the core node side; read them with `rpc.sharedState.get(key)` and `rpc.streaming.subscribe(channel, id)`. The paired request methods (`devframe:tracing:record` / `stop` / `clear`) are RPC endpoints typed in `types/rpc-augments.ts`, not events. + +| Name | Kind | Carries | +|---|---|---| +| `devframe:tracing:channels` | shared state | Known [Tracing Channels](/guide/tracing-channels) keyed by base name (`DevframeTracingChannelInfo`): source, description, `recording`, `count`, and the live `streamId` while recording. | +| `devframe:tracing` | streaming channel | Trace records (`DevframeTraceRecord`) of one recording Tracing Channel per stream; the stream id is the `streamId` from `devframe:tracing:channels`. A fresh id is issued on every `record()` and `clear()`. | + ### In-page channel notifications (page script → panel) Pushed over each panel's [in-page channel](/guide/in-page-channel) port; the paired request methods (`devframe:in-page:page-state:subscribe`/`set`/`patch`) are call endpoints defined at their handlers, not events. diff --git a/docs/content/8.references/4.node-api.md b/docs/content/8.references/4.node-api.md index 54e5475a..8283d294 100644 --- a/docs/content/8.references/4.node-api.md +++ b/docs/content/8.references/4.node-api.md @@ -25,6 +25,7 @@ The fields of a `DevframeDefinition`: [Devframe Definition](/guide/devframe-defi | `duplicationStrategy` | `'warn' \| 'silent' \| 'throw' \| 'duplicate'` | Hub reaction when another devframe shares this `id`. Default `'warn'`. See [Duplication strategies](/references/hub-api#duplication-strategies); standalone adapters ignore it. | | `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. `boolean` = whole runtime; object = individual features. | | `services` | `DevframeServiceInput[]` | Wire services consumed: descriptors (`{ package, version?, required?, options? }`) imported against the devframe's own deps, or ready definitions. See [Cross-Devframe Services](/guide/services#wire-services). | +| `tracing` | `{ channels?: (string \| { name, description? })[] }` | Node.js [Tracing Channels](/guide/tracing-channels) the app publishes, listed for inspection tooling. Registered before `setup(ctx)`; recording stays off until a client asks. | | `clientAssets` | `string \| RemoteAssets` | Built SPA served as the UI: local dist dir or [remote assets](/guide/client-assets). Read by every UI-serving adapter (`dev`, `build`, `vite`, `next`, hub). | | `rpc` | `{ snapshot?: (string \| { method, inputs })[] }` | RPC config. `rpc.snapshot` opts an RPC this devframe doesn't own into the static dump. Bare method id bakes the no-arg call; `{ method, inputs }` bakes one record per argument-tuple (`inputs` = tuples or async `(ctx) => tuples`). First tuple = fallback. | | `setup` | `(ctx, info?) => void \| Promise` | **Required.** Server-side entry point, run in every runtime. Optional 2nd arg carries runtime metadata, notably parsed CLI `flags` under `createCac`. | @@ -130,6 +131,20 @@ The methods on `ctx.services`: [Cross-Devframe Services](/guide/services#the-dev | `install` | `(input, options?) => Promise` | Install a [wire service](#wire-service-definition-fields) at runtime (the dynamic escape hatch; the common path is declarative). `options.resolveFrom` is the descriptor's resolution base. | | `ready` | `() => Promise` | **Internal.** Construct every queued wire service before any `setup` runs. Adapters call it; application code uses declarative `services`. | +## `DevframeTracingHost` + +The methods on `ctx.tracing`: [Tracing Channels](/guide/tracing-channels). + +| Method | Signature | Role | +|--------|-----------|------| +| `register` | `(nameOrChannel, { description? }?) => TracingChannel` | Declare a Tracing Channel and get Node's `TracingChannel` back for `traceSync` / `tracePromise`. Idempotent per name; accepts an existing instance. | +| `list` | `() => DevframeTracingChannelInfo[]` | Every known channel with `source` (`builtin` / `registered` / `config` / `adhoc`), `recording`, `count`, `streamId`. | +| `record` | `(name) => void` | Subscribe to the Node channel and fold its events into records. Unknown names are added as `adhoc`. Reports [`DF0081`](/errors/DF0081) once where `tracingChannel` is missing. | +| `stop` | `(name) => void` | Unsubscribe. Buffered records stay. | +| `records` | `(name) => DevframeTraceRecord[]` | Buffered records, oldest first, at most 500 per channel. | +| `clear` | `(name) => void` | Drop the buffer and, while recording, start a fresh stream. | +| `onRecord` | `(name, fn) => unsubscribe` | Run `fn` with the whole record on every lifecycle update. | + ## Service tiers The two tiers a service can take: [Cross-Devframe Services](/guide/services). diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index 8acd106f..8b63c6f6 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -86,7 +86,7 @@ What `initHub()` serves under its `base`: [The namespace](/guide/hub-initiate#th ## `buildHub` options -The options of `buildHub()` from `@devframes/hub/build`: [Static builds](/guide/hub-initiate#static-builds). `devframes`, `services`, `rpcDeclarations`, `configure`, `ui`, `renderers`, `name`, `version`, `cwd`, and `getStorageDir` carry the same contracts as their `initHub` counterparts. +The options of `buildHub()` from `@devframes/hub/build`: [Static builds](/guide/hub-initiate#static-builds). `devframes`, `services`, `tracing`, `rpcDeclarations`, `configure`, `ui`, `renderers`, `name`, `version`, `cwd`, and `getStorageDir` carry the same contracts as their `initHub` counterparts. | Option | Purpose | |---|---| diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index 21b7a9f0..f5e97262 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -322,9 +322,9 @@ describe('mcp adapter (in-memory)', () => { expect(tool).toBeDefined() expect(tool!.annotations?.readOnlyHint).toBe(true) - // No key → key list. + // No key → key list, alongside the core's own keys. const keys = await client.callTool({ name: 'devframe_state_read', arguments: {} }) - expect(keys.structuredContent).toEqual({ keys: ['my-plugin:counter'] }) + expect(keys.structuredContent).toEqual({ keys: ['devframe:tracing:channels', 'my-plugin:counter'] }) // With key → the value. const value = await client.callTool({ name: 'devframe_state_read', arguments: { key: 'my-plugin:counter' } }) diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index 2e58d440..1deaf587 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -84,6 +84,7 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt for (const input of d.services ?? []) void ctx.services.install(input, { resolveFrom: d.importMetaUrl }) await ctx.services.ready() + ctx.tracing._applyOptions(d.tracing) await d.setup(ctx) // Bake declared `rpc.snapshot` methods (typically a wire service's RPC the diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts index cc6275a6..5f46f470 100644 --- a/packages/devframe/src/adapters/embedded.ts +++ b/packages/devframe/src/adapters/embedded.ts @@ -22,5 +22,6 @@ export async function createEmbedded(d: DevframeDefinition, options: CreateEmbed for (const input of d.services ?? []) void options.ctx.services.install(input, { resolveFrom: d.importMetaUrl }) await options.ctx.services.ready() + options.ctx.tracing._applyOptions(d.tracing) await d.setup(options.ctx) } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 1a4dd516..4cf94732 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -304,6 +304,7 @@ export function initDevframe( for (const input of def.services ?? []) void context.services.install(input, { resolveFrom: def.importMetaUrl }) await context.services.ready() + context.tracing._applyOptions(def.tracing) await def.setup(context, setupInfo) const mcp = await mountMcpRoute(app, context, def, base, options.mcp ?? 'auto') diff --git a/packages/devframe/src/events.ts b/packages/devframe/src/events.ts index ebc958d6..87e42c1d 100644 --- a/packages/devframe/src/events.ts +++ b/packages/devframe/src/events.ts @@ -57,6 +57,21 @@ export const DEVFRAME_EVENTS = { streamingEnd: 'devframe:streaming:end', streamingUploadCancel: 'devframe:streaming:upload-cancel', }, + /** + * Shared-state keys the core node side publishes. The paired request + * methods are the generic `devframe:rpc:server-state:*` endpoints. + */ + sharedState: { + tracingChannels: 'devframe:tracing:channels', + }, + /** + * Streaming channels the core node side owns. Each recording Tracing + * Channel gets its own stream on `devframe:tracing`; the id is published + * as `streamId` in the `devframe:tracing:channels` shared state. + */ + stream: { + tracing: 'devframe:tracing', + }, /** * In-page channel notifications the page script pushes to its panels * (page script → panel), `devframe:` prefix. The paired request methods diff --git a/packages/devframe/src/node/__tests__/host-tracing.test.ts b/packages/devframe/src/node/__tests__/host-tracing.test.ts new file mode 100644 index 00000000..3d8b93e7 --- /dev/null +++ b/packages/devframe/src/node/__tests__/host-tracing.test.ts @@ -0,0 +1,251 @@ +import type { DevframeHost, DevframeNodeContext, DevframeRpcServerFunctions, DevframeTraceRecord, DevframeTracingChannelInfo } from 'devframe/types' +import * as diagnosticsChannel from 'node:diagnostics_channel' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRpcStreamingClientHost } from 'devframe/client' +import { DEVFRAME_EVENTS } from 'devframe/constants' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' +import { getPort } from 'get-port-please' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { createHostContext } from '../context' +import { RpcFunctionsHostImpl } from '../host-functions' +import { DevframeTracingHostImpl, TRACING_RECORD_BUFFER } from '../host-tracing' +import { createContextRpcServer } from '../rpc-core' + +vi.stubGlobal('WebSocket', WebSocket) + +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) + rmSync(dir, { recursive: true, force: true }) +}) + +function createTestHost(dir: string): DevframeHost { + return { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: scope => join(dir, scope), + } +} + +async function createCtx(): Promise { + const dir = mkdtempSync(join(tmpdir(), 'devframe-tracing-')) + tempDirs.push(dir) + return createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) }) +} + +async function channelsState(ctx: DevframeNodeContext): Promise> { + const state = await ctx.rpc.sharedState.get>(DEVFRAME_EVENTS.sharedState.tracingChannels) + await new Promise(resolve => setTimeout(resolve, 0)) + return state.value() as Record +} + +let channelSeq = 0 +function uniqueName(): string { + return `test:tracing-${process.pid}-${channelSeq++}` +} + +describe('devframeTracingHost', () => { + it('registers by name or instance, idempotently, and ranks sources', async () => { + const ctx = await createCtx() + const name = uniqueName() + const channel = ctx.tracing.register(name, { description: 'first' }) + expect(channel.start).toBe(diagnosticsChannel.channel(`tracing:${name}:start`)) + expect(ctx.tracing.register(name, { description: 'ignored' })).toBe(channel) + + const instanceName = uniqueName() + const existing = diagnosticsChannel.tracingChannel(instanceName) + expect(ctx.tracing.register(existing)).toBe(existing) + + const configName = uniqueName() + ctx.tracing._applyOptions({ channels: [configName, { name, description: 'ignored too' }] }) + ctx.tracing.record(uniqueName()) + + const byName = Object.fromEntries(ctx.tracing.list().map(info => [info.name, info])) + expect(byName['module.require']).toMatchObject({ source: 'builtin', recording: false, count: 0 }) + expect(byName['net.server.listen']).toMatchObject({ source: 'builtin' }) + expect(byName[name]).toMatchObject({ source: 'registered', description: 'first' }) + expect(byName[instanceName]).toMatchObject({ source: 'registered' }) + expect(byName[configName]).toMatchObject({ source: 'config' }) + expect(Object.values(byName).filter(info => info.source === 'adhoc')).toHaveLength(1) + + const state = await channelsState(ctx) + expect(Object.keys(state).sort()).toEqual(Object.keys(byName).sort()) + }) + + it('records sync and async traces as grouped records while recording', async () => { + const ctx = await createCtx() + const name = uniqueName() + const channel = ctx.tracing.register(name) + const seen: DevframeTraceRecord[] = [] + ctx.tracing.onRecord(name, record => seen.push(record)) + + channel.traceSync(() => 'ignored before record', { id: 0 }) + expect(ctx.tracing.records(name)).toEqual([]) + + ctx.tracing.record(name) + expect(channel.hasSubscribers).toBe(true) + + const sync = channel.traceSync((n: number) => n * 2, { id: 1, socket: { write() {} } }, undefined, 21) + expect(sync).toBe(42) + const async = await channel.tracePromise(async () => 'done', { id: 2 }) + expect(async).toBe('done') + expect(() => channel.traceSync(() => { + throw new TypeError('boom') + }, { id: 3 })).toThrow('boom') + + const records = ctx.tracing.records(name) + expect(records.map(record => record.status)).toEqual(['ok', 'ok', 'error']) + expect(records[0]).toMatchObject({ channel: name, context: { id: 1, socket: {} }, result: 42 }) + expect(records[0]!.events.map(event => event.phase)).toEqual(['start', 'end']) + expect(records[0]!.duration).toBeGreaterThanOrEqual(0) + expect(records[1]!.events.map(event => event.phase)).toEqual(['start', 'end', 'asyncStart', 'asyncEnd']) + expect(records[1]).toMatchObject({ result: 'done' }) + expect(records[2]!.error).toMatchObject({ name: 'TypeError', message: 'boom' }) + expect(records[2]!.events.map(event => event.phase)).toEqual(['start', 'error', 'end']) + + // One listener call per lifecycle event, each carrying the whole record. + expect(seen).toHaveLength(2 + 4 + 3) + expect(seen.at(-1)).toMatchObject({ id: records[2]!.id, status: 'error' }) + + ctx.tracing.stop(name) + expect(channel.hasSubscribers).toBe(false) + channel.traceSync(() => 'after stop', { id: 4 }) + expect(ctx.tracing.records(name)).toHaveLength(3) + const state = await channelsState(ctx) + expect(state[name]).toMatchObject({ recording: false, count: 3 }) + expect(state[name]!.streamId).toBeUndefined() + }) + + it('caps the buffer and clears it', async () => { + const ctx = await createCtx() + const name = uniqueName() + const channel = ctx.tracing.register(name) + ctx.tracing.record(name) + for (let i = 0; i < TRACING_RECORD_BUFFER + 5; i++) + channel.traceSync(() => i, { i }) + const records = ctx.tracing.records(name) + expect(records).toHaveLength(TRACING_RECORD_BUFFER) + expect(records[0]!.context).toEqual({ i: 5 }) + + const before = (await channelsState(ctx))[name]! + expect(before.recording).toBe(true) + ctx.tracing.clear(name) + expect(ctx.tracing.records(name)).toEqual([]) + const after = (await channelsState(ctx))[name]! + expect(after.count).toBe(0) + expect(after.recording).toBe(true) + expect(after.streamId).not.toBe(before.streamId) + }) + + it('does not record its own wire RPC', async () => { + const ctx = await createCtx() + const rpcName = uniqueName() + const channel = ctx.tracing.register(rpcName) + ctx.tracing.record(rpcName) + for (const method of ['devframe:tracing:record', 'devframe:tracing:stop', 'devframe:tracing:clear'] as const) + await ctx.rpc.invokeLocal(method, uniqueName()) + expect(ctx.tracing.records(rpcName)).toEqual([]) + expect(channel.hasSubscribers).toBe(true) + }) + + it('streams records to a client, with replay on resubscribe', async () => { + const ctx = await createCtx() + const port = await getPort({ host: '127.0.0.1', random: true }) + const server = createContextRpcServer({ context: ctx, auth: false }) + const { close } = attachWsRpcTransport(server.rpcGroup, { + port, + host: '127.0.0.1', + onConnected: server.onConnected, + onDisconnected: server.onDisconnected, + }) + + const name = uniqueName() + const channel = ctx.tracing.register(name) + try { + const client = bootClient(port) + await client.rpc.$call('devframe:tracing:record', name) + const streamId = (await channelsState(ctx))[name]!.streamId! + channel.traceSync(() => 1, { n: 1 }) + + const reader = client.streaming.subscribe(DEVFRAME_EVENTS.stream.tracing, streamId) + const first = await reader[Symbol.asyncIterator]().next() + expect(first.value).toMatchObject({ channel: name, context: { n: 1 }, status: 'pending' }) + client.close() + + // A fresh client replays the buffered chunks from the start. + const again = bootClient(port) + const replayed = again.streaming.subscribe(DEVFRAME_EVENTS.stream.tracing, streamId) + const chunks: DevframeTraceRecord[] = [] + for await (const chunk of replayed) { + chunks.push(chunk) + if (chunks.length === 2) + break + } + expect(chunks.map(chunk => chunk.status)).toEqual(['pending', 'ok']) + again.close() + } + finally { + await close() + } + }) + + it('is a no-op that warns once where tracingChannel is missing', async () => { + const ctx = await createCtx() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const host = new DevframeTracingHostImpl( + { ...ctx, rpc: new RpcFunctionsHostImpl(ctx) }, + { channel: diagnosticsChannel.channel }, + ) + expect(host.supported).toBe(false) + const name = uniqueName() + const channel = host.register(name) + expect(channel.traceSync(() => 'ran')).toBe('ran') + await expect(channel.tracePromise(async () => 'ran')).resolves.toBe('ran') + expect(channel.hasSubscribers).toBe(false) + + host.record(name) + host.record(name) + expect(host.list().find(info => info.name === name)).toMatchObject({ recording: false }) + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0]![0])).toContain('DF0081') + warn.mockRestore() + }) +}) + +interface FakeClient { + rpc: ReturnType any>>> + streaming: ReturnType + close: () => void +} + +function bootClient(port: number): FakeClient { + const clientFns: Record any> = {} + const rpc = createRpcClient any>>( + clientFns, + { channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }) }, + ) + // Mimics the `DevframeRpcClient` surface `createRpcStreamingClientHost` uses. + const fakeRpcClient = { + isTrusted: true, + events: { on: () => () => {} }, + client: { + register(def: { name: string, handler: (...args: any[]) => any }) { + clientFns[def.name] = def.handler + }, + }, + callEvent: (name: string, ...args: any[]) => (rpc as any).$callEvent(name, ...args), + } as any + const streaming = createRpcStreamingClientHost(fakeRpcClient) + return { + rpc, + streaming, + close() { + (rpc as any).$close() + }, + } +} diff --git a/packages/devframe/src/node/__tests__/tracing-serialize.test.ts b/packages/devframe/src/node/__tests__/tracing-serialize.test.ts new file mode 100644 index 00000000..c7edf4b6 --- /dev/null +++ b/packages/devframe/src/node/__tests__/tracing-serialize.test.ts @@ -0,0 +1,73 @@ +import { Socket } from 'node:net' +import { describe, expect, it } from 'vitest' +import { serializeTraceValue } from '../tracing-serialize' + +describe('serializeTraceValue', () => { + it('passes primitives through and stringifies the ones JSON cannot hold', () => { + expect(serializeTraceValue({ a: 1, b: 'x', c: true, d: null, e: 10n, f: Number.NaN })) + .toEqual({ a: 1, b: 'x', c: true, d: null, e: '10n', f: 'NaN' }) + }) + + it('drops functions, symbols and undefined from objects, nulls them in arrays', () => { + expect(serializeTraceValue({ fn() {}, sym: Symbol('s'), u: undefined, list: [() => 1, undefined, 2] })) + .toEqual({ list: [null, null, 2] }) + expect(serializeTraceValue(() => 1)).toBeNull() + }) + + it('caps depth and marks cycles only for true cycles', () => { + const shared = { leaf: 1 } + const cyclic: Record = { shared, again: shared } + cyclic.self = cyclic + expect(serializeTraceValue(cyclic)).toEqual({ + shared: { leaf: 1 }, + again: { leaf: 1 }, + self: '[Circular]', + }) + expect(serializeTraceValue({ a: { b: { c: { d: { e: 1 } } } } }, { maxDepth: 3 })) + .toEqual({ a: { b: { c: '[Object]' } } }) + expect(serializeTraceValue([[[[1]]]], { maxDepth: 2 })).toEqual([['[Array]']]) + }) + + it('truncates long strings and wide objects', () => { + const long = 'x'.repeat(10) + expect(serializeTraceValue(long, { maxString: 4 })).toBe('xxxx… (6 more chars)') + const wide = Object.fromEntries(Array.from({ length: 5 }, (_, i) => [`k${i}`, i])) + expect(serializeTraceValue(wide, { maxKeys: 2 })).toEqual({ 'k0': 0, 'k1': 1, '…': '3 more keys' }) + }) + + it('represents errors, dates, maps, sets and binary data', () => { + const error = new TypeError('boom') + const out = serializeTraceValue({ + error, + date: new Date('2026-01-02T03:04:05.000Z'), + map: new Map([['k', { v: 1 }]]), + set: new Set([1, 2]), + bytes: new Uint8Array(3), + buffer: new ArrayBuffer(8), + }) as Record + expect(out.error).toMatchObject({ name: 'TypeError', message: 'boom' }) + expect((out.error as { stack: string }).stack).toContain('boom') + expect(out.date).toBe('2026-01-02T03:04:05.000Z') + expect(out.map).toEqual([['k', { v: 1 }]]) + expect(out.set).toEqual([1, 2]) + expect(out.bytes).toBe('[Uint8Array 3]') + expect(out.buffer).toBe('[ArrayBuffer 8]') + }) + + it('never throws on a request-like object holding a socket and a throwing getter', () => { + const socket = new Socket() + const request = { + method: 'GET', + url: '/x', + socket, + get poison(): never { + throw new Error('no access') + }, + } + const out = serializeTraceValue(request) as Record + expect(out.method).toBe('GET') + expect(typeof out.socket).toBe('object') + expect(out.poison).toMatchObject({ name: 'Error', message: 'no access' }) + socket.destroy() + }) +}) diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts index 6fd6c372..771d4d6a 100644 --- a/packages/devframe/src/node/context.ts +++ b/packages/devframe/src/node/context.ts @@ -6,6 +6,7 @@ import { DevframeAgentHost } from './host-agent' import { DevframeDiagnosticsHost } from './host-diagnostics' import { RpcFunctionsHostImpl } from './host-functions' import { DevframeServicesHostImpl } from './host-services' +import { DevframeTracingHostImpl } from './host-tracing' import { DevframeViewHost } from './host-views' import { BUILTIN_AGENT_RPC } from './rpc' import { createScopedNodeContext } from './scope' @@ -53,6 +54,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom diagnostics: undefined!, agent: undefined!, services: undefined!, + tracing: undefined!, staticConfig: {}, scope: undefined!, } @@ -64,6 +66,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom context.views = viewsHost context.diagnostics = diagnosticsHost context.services = new DevframeServicesHostImpl(context) + context.tracing = new DevframeTracingHostImpl(context) // Agent host must be constructed after `rpcHost` so it can subscribe // to `onChanged`; it auto-discovers RPC functions flagged with diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 7a5b9a95..19ebfa3a 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -220,5 +220,10 @@ export const diagnostics = defineDiagnostics({ `The \`mcp\` option is enabled, but the optional peer "@devframes/agentic" could not be loaded: ${p.reason}`, fix: 'Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting.', }, + DF0081: { + why: (p: { runtime: string }) => + `Tracing Channels are unavailable: this runtime (${p.runtime}) does not provide \`node:diagnostics_channel\`'s \`tracingChannel\`, so \`ctx.tracing\` records nothing.`, + fix: 'Run under Node.js 22+ or Bun to record Tracing Channels. Every `ctx.tracing` method is a no-op here.', + }, }, }) diff --git a/packages/devframe/src/node/host-tracing.ts b/packages/devframe/src/node/host-tracing.ts new file mode 100644 index 00000000..16a661b6 --- /dev/null +++ b/packages/devframe/src/node/host-tracing.ts @@ -0,0 +1,297 @@ +import type { + DevframeNodeContext, + DevframeTracePhase, + DevframeTraceRecord, + DevframeTracingChannelInfo, + DevframeTracingChannelSource, + DevframeTracingHost, + DevframeTracingOptions, + RpcStreamingChannel, +} from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { StreamSink } from 'devframe/utils/streaming-channel' +import type { TracingChannel, TracingChannelSubscribers } from 'node:diagnostics_channel' +import * as diagnosticsChannel from 'node:diagnostics_channel' +import { nanoid } from 'devframe/utils/nanoid' +import { DEVFRAME_EVENTS } from '../events' +import { diagnostics } from './diagnostics' +import { detectServerRuntime } from './runtime' +import { serializeTraceValue } from './tracing-serialize' + +export const TRACING_RECORD_BUFFER = 500 +const COUNT_FLUSH_MS = 100 + +/** Tracing Channels Node.js itself publishes. */ +const BUILTIN_CHANNELS: Record = { + 'module.require': 'Every CommonJS require() call', + 'net.server.listen': 'net.Server listen() calls', +} + +/** Later sources win when the same name arrives twice. */ +const SOURCE_RANK: Record = { + adhoc: 0, + builtin: 1, + config: 2, + registered: 3, +} + +type TracingModule = Pick & Partial> + +interface ChannelEntry { + info: DevframeTracingChannelInfo + channel: TracingChannel + records: DevframeTraceRecord[] + byContext: WeakMap + subscribers?: Partial> + sink?: StreamSink + listeners: Set<(record: DevframeTraceRecord) => void> +} + +export class DevframeTracingHostImpl implements DevframeTracingHost { + readonly supported: boolean + private readonly entries = new Map() + private readonly stream: RpcStreamingChannel + private readonly state: Promise>> + private countTimer: ReturnType | undefined + private warnedUnsupported = false + + constructor( + private readonly context: DevframeNodeContext, + private readonly module: TracingModule = diagnosticsChannel, + ) { + this.supported = typeof module.tracingChannel === 'function' + this.stream = context.rpc.streaming.create(DEVFRAME_EVENTS.stream.tracing, { + replayWindow: TRACING_RECORD_BUFFER, + closedStreamRetention: 0, + }) + this.state = context.rpc.sharedState.get>( + DEVFRAME_EVENTS.sharedState.tracingChannels, + { initialValue: {} }, + ) + for (const [name, description] of Object.entries(BUILTIN_CHANNELS)) + this.add(name, 'builtin', description) + this.registerRpc() + } + + _applyOptions(options: DevframeTracingOptions | undefined): void { + for (const entry of options?.channels ?? []) { + const { name, description } = typeof entry === 'string' ? { name: entry, description: undefined } : entry + this.add(name, 'config', description) + } + } + + register(channel: string | TracingChannel, meta?: { description?: string }): TracingChannel { + const name = typeof channel === 'string' ? channel : baseName(channel) + return this.add(name, 'registered', meta?.description, typeof channel === 'string' ? undefined : channel).channel + } + + list(): DevframeTracingChannelInfo[] { + return Array.from(this.entries.values(), entry => ({ ...entry.info })) + } + + record(name: string): void { + if (!this.supported) { + if (!this.warnedUnsupported) { + this.warnedUnsupported = true + diagnostics.DF0081({ runtime: detectServerRuntime() }) + } + return + } + const entry = this.add(name, 'adhoc') + if (entry.subscribers) + return + entry.sink = this.stream.start() + entry.info.recording = true + entry.info.streamId = entry.sink.id + entry.subscribers = { + start: message => this.onPhase(entry, 'start', message), + end: message => this.onPhase(entry, 'end', message), + asyncStart: message => this.onPhase(entry, 'asyncStart', message), + asyncEnd: message => this.onPhase(entry, 'asyncEnd', message), + error: message => this.onPhase(entry, 'error', message), + } + entry.channel.subscribe(entry.subscribers) + void this.publish() + } + + stop(name: string): void { + const entry = this.entries.get(name) + if (!entry?.subscribers) + return + entry.channel.unsubscribe(entry.subscribers) + entry.subscribers = undefined + entry.sink?.close() + entry.sink = undefined + entry.info.recording = false + entry.info.streamId = undefined + void this.publish() + } + + records(name: string): DevframeTraceRecord[] { + return (this.entries.get(name)?.records ?? []).map(record => ({ ...record })) + } + + clear(name: string): void { + const entry = this.entries.get(name) + if (!entry) + return + entry.records = [] + entry.byContext = new WeakMap() + entry.info.count = 0 + if (entry.sink) { + // A fresh stream drops the replay buffer, so a client that reconnects + // after a clear does not see the cleared records again. + entry.sink.close() + entry.sink = this.stream.start() + entry.info.streamId = entry.sink.id + } + void this.publish() + } + + onRecord(name: string, fn: (record: DevframeTraceRecord) => void): () => void { + const entry = this.add(name, 'adhoc') + entry.listeners.add(fn) + return () => { + entry.listeners.delete(fn) + } + } + + private add(name: string, source: DevframeTracingChannelSource, description?: string, channel?: TracingChannel): ChannelEntry { + let entry = this.entries.get(name) + if (!entry) { + entry = { + info: { name, source, recording: false, count: 0 }, + channel: channel ?? this.createChannel(name), + records: [], + byContext: new WeakMap(), + listeners: new Set(), + } + this.entries.set(name, entry) + } + else if (SOURCE_RANK[source] > SOURCE_RANK[entry.info.source]) { + entry.info.source = source + } + if (description && !entry.info.description) + entry.info.description = description + void this.publish() + return entry + } + + private createChannel(name: string): TracingChannel { + if (this.module.tracingChannel) + return this.module.tracingChannel(name) + return noopTracingChannel(this.module, name) + } + + private onPhase(entry: ChannelEntry, phase: DevframeTracePhase, message: unknown): void { + const at = Date.now() + const keyed = typeof message === 'object' && message !== null + const record = (keyed ? entry.byContext.get(message) : undefined) ?? this.openRecord(entry, at, keyed ? message : undefined) + + record.events.push({ phase, at }) + record.duration = at - record.startedAt + const { result, error, ...rest } = keyed ? (message as { result?: unknown, error?: unknown }) : {} + record.context = serializeTraceValue(keyed ? rest : message) + if (result !== undefined) + record.result = serializeTraceValue(result) + if (phase === 'error') { + record.status = 'error' + const serialized = serializeTraceValue(error) + record.error = isErrorShape(serialized) ? serialized : { name: 'Error', message: String(error) } + } + else if ((phase === 'end' || phase === 'asyncEnd') && record.status === 'pending') { + record.status = 'ok' + } + + const snapshot = { ...record, events: [...record.events] } + entry.sink?.write(snapshot) + for (const fn of entry.listeners) + fn(snapshot) + } + + private openRecord(entry: ChannelEntry, at: number, context: object | undefined): DevframeTraceRecord { + const record: DevframeTraceRecord = { + id: nanoid(), + channel: entry.info.name, + startedAt: at, + status: 'pending', + context: null, + events: [], + } + if (context) + entry.byContext.set(context, record) + entry.records.push(record) + if (entry.records.length > TRACING_RECORD_BUFFER) + entry.records.shift() + entry.info.count = entry.records.length + this.scheduleCountFlush() + return record + } + + /** Counts change on every record; batch them so the state is not spammed. */ + private scheduleCountFlush(): void { + if (this.countTimer) + return + this.countTimer = setTimeout(() => { + this.countTimer = undefined + void this.publish() + }, COUNT_FLUSH_MS) + } + + private async publish(): Promise { + const state = await this.state + state.mutate((value) => { + for (const entry of this.entries.values()) + value[entry.info.name] = { ...entry.info } + }) + } + + private registerRpc(): void { + const rpc = this.context.rpc + rpc.register({ + name: 'devframe:tracing:record', + type: 'action', + handler: async (name: string) => this.record(name), + }) + rpc.register({ + name: 'devframe:tracing:stop', + type: 'action', + handler: async (name: string) => this.stop(name), + }) + rpc.register({ + name: 'devframe:tracing:clear', + type: 'action', + handler: async (name: string) => this.clear(name), + }) + } +} + +/** `tracing::start` → ``. */ +function baseName(channel: TracingChannel): string { + const name = String(channel.start.name) + return name.replace(/^tracing:/, '').replace(/:start$/, '') +} + +function isErrorShape(value: unknown): value is { name: string, message: string, stack?: string } { + return typeof value === 'object' && value !== null && typeof (value as { name?: unknown }).name === 'string' && typeof (value as { message?: unknown }).message === 'string' +} + +/** + * Stand-in for runtimes without `tracingChannel`: the trace helpers call + * the function directly and nothing is published. + */ +function noopTracingChannel(module: TracingModule, name: string): TracingChannel { + return { + start: module.channel(`tracing:${name}:start`), + end: module.channel(`tracing:${name}:end`), + asyncStart: module.channel(`tracing:${name}:asyncStart`), + asyncEnd: module.channel(`tracing:${name}:asyncEnd`), + error: module.channel(`tracing:${name}:error`), + hasSubscribers: false, + subscribe() {}, + unsubscribe() {}, + traceSync: (fn, _context, thisArg, ...args) => Reflect.apply(fn, thisArg, args), + tracePromise: (fn, _context, thisArg, ...args) => Reflect.apply(fn, thisArg, args), + traceCallback: (fn, _position, _context, thisArg, ...args) => Reflect.apply(fn, thisArg, args), + } +} diff --git a/packages/devframe/src/node/scope.ts b/packages/devframe/src/node/scope.ts index 7888606e..40fa98ee 100644 --- a/packages/devframe/src/node/scope.ts +++ b/packages/devframe/src/node/scope.ts @@ -56,6 +56,7 @@ export function createScopedNodeContext( views: context.views, diagnostics: context.diagnostics, agent: context.agent, + tracing: context.tracing, scope: context.scope, } } diff --git a/packages/devframe/src/node/tracing-serialize.ts b/packages/devframe/src/node/tracing-serialize.ts new file mode 100644 index 00000000..9e1a2405 --- /dev/null +++ b/packages/devframe/src/node/tracing-serialize.ts @@ -0,0 +1,93 @@ +import type { SerializedValue } from 'devframe/types' + +export interface SerializeTraceValueOptions { + maxDepth?: number + maxString?: number + maxKeys?: number +} + +/** + * Flatten an arbitrary trace payload (request objects, sockets, errors, + * cycles) into a JSON-shaped value. Never throws: anything it cannot + * represent becomes a short placeholder string or is dropped. + */ +export function serializeTraceValue(value: unknown, options: SerializeTraceValueOptions = {}): SerializedValue { + const { maxDepth = 4, maxString = 2000, maxKeys = 50 } = options + const seen = new WeakSet() + + function visit(input: unknown, depth: number): SerializedValue | undefined { + switch (typeof input) { + case 'string': + return input.length > maxString ? `${input.slice(0, maxString)}… (${input.length - maxString} more chars)` : input + case 'number': + return Number.isFinite(input) ? input : String(input) + case 'boolean': + return input + case 'bigint': + return `${input}n` + case 'undefined': + case 'function': + case 'symbol': + return undefined + } + if (input === null) + return null + return visitObject(input as object, depth) + } + + function visitObject(input: object, depth: number): SerializedValue { + if (input instanceof Error) { + return { + name: input.name, + message: input.message, + ...(input.stack ? { stack: input.stack } : {}), + } + } + if (input instanceof Date) + return Number.isNaN(input.getTime()) ? 'Invalid Date' : input.toISOString() + if (ArrayBuffer.isView(input)) + return `[${input.constructor.name} ${input.byteLength}]` + if (input instanceof ArrayBuffer) + return `[ArrayBuffer ${input.byteLength}]` + + if (seen.has(input)) + return '[Circular]' + const isList = Array.isArray(input) || input instanceof Set || input instanceof Map + if (depth >= maxDepth) + return isList ? '[Array]' : '[Object]' + seen.add(input) + try { + return visitChildren(input, depth) + } + finally { + seen.delete(input) + } + } + + function visitChildren(input: object, depth: number): SerializedValue { + if (Array.isArray(input) || input instanceof Set) + return Array.from(input, item => visit(item, depth + 1) ?? null) + if (input instanceof Map) + return Array.from(input, ([k, v]) => [visit(k, depth + 1) ?? null, visit(v, depth + 1) ?? null]) + + const out: { [key: string]: SerializedValue } = {} + const keys = Object.keys(input) + for (const key of keys.slice(0, maxKeys)) { + let raw: unknown + try { + raw = (input as Record)[key] + } + catch (error) { + raw = error + } + const serialized = visit(raw, depth + 1) + if (serialized !== undefined) + out[key] = serialized + } + if (keys.length > maxKeys) + out['…'] = `${keys.length - maxKeys} more keys` + return out + } + + return visit(value, 0) ?? null +} diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 1d7881c9..6866fbc9 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -3,6 +3,7 @@ import type { DevframeDiagnosticsHost } from './diagnostics' import type { DevframeHost } from './host' import type { DevframeScopedNodeContext, SettingsForNamespace } from './scope' import type { DevframeServicesHost } from './services' +import type { DevframeTracingHost } from './tracing' import type { DevframeViewHost } from './views' export interface DevframeCapabilities { @@ -62,6 +63,11 @@ export interface DevframeNodeContext { * absorb setup-order differences between provider and consumer. */ services: DevframeServicesHost + /** + * Node.js Tracing Channel host: lists known `TracingChannel`s, subscribes + * on demand, and folds their lifecycle events into trace records. + */ + tracing: DevframeTracingHost /** * This context's own {@link ConnectionMeta.configs}: static, boot-time * config a host publishes once through the connection handshake and every diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index f1a17371..2117f8d4 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -4,6 +4,7 @@ import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeNodeContext } from './context' import type { StaticAssetsSource } from './remote-assets' import type { DevframeServiceInput } from './services' +import type { DevframeTracingOptions } from './tracing' /** * Classification of how a devframe is being deployed. Hosted adapters @@ -419,6 +420,12 @@ export interface DevframeDefinition { * `client.services.has(pkg)` and degrade. */ services?: DevframeServiceInput[] + /** + * Node.js Tracing Channels the app publishes, listed for inspection + * tooling. Each entry is registered through `ctx.tracing.register()` + * before `setup(ctx)` runs. Recording stays off until a client asks. + */ + tracing?: DevframeTracingOptions /** * Author's SPA dist, served as the devframe's UI. A local directory, or * a {@link StaticAssetsSource} remote declaration (`{ package, version }`) diff --git a/packages/devframe/src/types/index.ts b/packages/devframe/src/types/index.ts index a406a920..84713767 100644 --- a/packages/devframe/src/types/index.ts +++ b/packages/devframe/src/types/index.ts @@ -10,4 +10,5 @@ export * from './rpc' export * from './rpc-augments' export * from './scope' export * from './services' +export * from './tracing' export * from './views' diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 05a5d2cf..77541d92 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -164,6 +164,29 @@ export interface DevframeRpcServerFunctions { * @internal */ 'devframe:streaming:upload-end': (channel: string, id: string, error?: { name: string, message: string }) => Promise + /** + * Subscribe to a Tracing Channel and start folding its events into + * records on a `devframe:tracing` stream (id published as `streamId` in + * `devframe:tracing:channels`). Wired by `DevframeTracingHost`; do not + * register manually. + * + * @internal + */ + 'devframe:tracing:record': (name: string) => Promise + /** + * Unsubscribe from a Tracing Channel. Buffered records stay. Wired by + * `DevframeTracingHost`; do not register manually. + * + * @internal + */ + 'devframe:tracing:stop': (name: string) => Promise + /** + * Drop a Tracing Channel's buffered records. Wired by + * `DevframeTracingHost`; do not register manually. + * + * @internal + */ + 'devframe:tracing:clear': (name: string) => Promise } /** @@ -178,4 +201,10 @@ export interface DevframeRpcSharedStates { * reactivity). Read-only from the browser. */ 'devframe:services': import('./services').DevframeServicesState + /** + * Known Tracing Channels keyed by base name, with their source and + * recording status. Written by the node tracing host. Read-only from the + * browser. + */ + 'devframe:tracing:channels': Record } diff --git a/packages/devframe/src/types/scope.ts b/packages/devframe/src/types/scope.ts index 128fc1d7..fc442c3b 100644 --- a/packages/devframe/src/types/scope.ts +++ b/packages/devframe/src/types/scope.ts @@ -11,6 +11,7 @@ import type { RpcStreamingChannelOptions, } from './rpc' import type { DevframeRpcClientFunctions, DevframeRpcServerFunctions, DevframeRpcSharedStates } from './rpc-augments' +import type { DevframeTracingHost } from './tracing' import type { DevframeViewHost } from './views' // Callable guard so `Parameters` / `ReturnType` always have a function to @@ -218,6 +219,7 @@ export interface DevframeScopedNodeContext +} + +/** + * Node-side Tracing Channel host, exposed as `ctx.tracing`. Wraps + * `node:diagnostics_channel`'s `TracingChannel`: keeps the list of known + * channels, subscribes on demand, folds lifecycle events into + * {@link DevframeTraceRecord}s, and advertises both over RPC. + * + * On a runtime without `tracingChannel` every method is a no-op; + * `record()` reports `DF0081` once. + */ +export interface DevframeTracingHost { + /** + * Declare a Tracing Channel and get the Node `TracingChannel` back, so a + * producer can call `tracePromise` / `traceSync` on it. Idempotent: the + * same name returns the same channel. Accepts an existing instance too. + */ + register: (channel: string | TracingChannel, meta?: { description?: string }) => TracingChannel + list: () => DevframeTracingChannelInfo[] + /** Subscribe to the channel and start folding events into records. */ + record: (name: string) => void + /** Unsubscribe. Records stay in the buffer. */ + stop: (name: string) => void + /** Buffered records, oldest first. */ + records: (name: string) => DevframeTraceRecord[] + clear: (name: string) => void + /** Called with the whole record on every lifecycle update. */ + onRecord: (name: string, fn: (record: DevframeTraceRecord) => void) => () => void + /** + * Adapters call this with a definition's or hub's `tracing` field before + * `setup()` runs. Wired by the adapters automatically. + * + * @internal + */ + _applyOptions: (options: DevframeTracingOptions | undefined) => void +} diff --git a/packages/hub/src/node/__tests__/install-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts index 1bd28d69..c95870e7 100644 --- a/packages/hub/src/node/__tests__/install-devframe.test.ts +++ b/packages/hub/src/node/__tests__/install-devframe.test.ts @@ -38,6 +38,9 @@ function createContext(): DevframeHubContext { install: () => Promise.resolve(undefined), ready: () => Promise.resolve(), }, + tracing: { + _applyOptions: () => {}, + }, } const context = partial as DevframeHubContext context.docks = new DevframeDocksHost(context) diff --git a/packages/hub/src/node/build.ts b/packages/hub/src/node/build.ts index 71526a29..ad2fa212 100644 --- a/packages/hub/src/node/build.ts +++ b/packages/hub/src/node/build.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import type { ConnectionMeta, DevframeServiceInput, DevframeStorageScope } from 'devframe/types' +import type { ConnectionMeta, DevframeServiceInput, DevframeStorageScope, DevframeTracingOptions } from 'devframe/types' import type { ClientScriptEntry } from '../types/docks' import type { CreateHubContextOptions, DevframeHubContext } from './context' import type { DevframeHubUi, DevframesInput, DockRendererRegistration } from './initiate' @@ -50,6 +50,8 @@ export interface BuildHubOptions { context?: DevframeHubContext /** Host-level wire services, same contract as `initHub({ services })`. */ services?: DevframeServiceInput[] + /** Host-level Tracing Channels, same contract as `initHub({ tracing })`. */ + tracing?: DevframeTracingOptions /** Extra RPC declarations registered at context creation. */ rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations'] /** @@ -183,6 +185,7 @@ async function createAndMountContext(options: BuildHubOptions, base: string, cwd const devframes = await resolveDevframesInput(options.devframes ?? []) for (const input of options.services ?? []) void ctx.services.install(input) + ctx.tracing._applyOptions(options.tracing) const setups = await mountDevframes(ctx, devframes, base) await ctx.services.ready() diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 3edb91ed..0b68cd48 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -2,7 +2,7 @@ import type { AgenticMcpModule, DevframeInstanceRecord, InstanceShellApi, Resolv import type { DevframeAuthHandler } from 'devframe/node/auth' import type { AuthBannerFunction } from 'devframe/recipes/interactive-auth' import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' -import type { ConnectionMeta, DevframeDefinition, DevframeServiceInput, DevframeSseOptions, DevframeStorageScope, DevframeWsOptions, McpRouteOptions, McpSetting } from 'devframe/types' +import type { ConnectionMeta, DevframeDefinition, DevframeServiceInput, DevframeSseOptions, DevframeStorageScope, DevframeTracingOptions, DevframeWsOptions, McpRouteOptions, McpSetting } from 'devframe/types' import type { Buffer } from 'node:buffer' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' import type { Duplex } from 'node:stream' @@ -176,6 +176,11 @@ export interface InitHubOptions { * `services: [createShikiService({ themes })]`. */ services?: DevframeServiceInput[] + /** + * Host-level Node.js Tracing Channels to list, on top of whatever the + * mounted devframes declare in their own `tracing` field. + */ + tracing?: DevframeTracingOptions /** * Extra RPC declarations registered at context creation, alongside the * hub built-ins, forwarded to `createHubContext`'s @@ -463,6 +468,7 @@ export function initHub(options: InitHubOptions): HubInstance { // collection alongside every devframe's own declared services. for (const input of options.services ?? []) void ctx.services.install(input) + ctx.tracing._applyOptions(options.tracing) const setups = await mountDevframes(ctx, devframes, base) // Construct every collected service once, then run the setups, so a diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 258b41f0..706eb585 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -153,6 +153,7 @@ export async function prepareDevframe( // the `ctx.services.ready()` barrier the hub fires before running setups. for (const input of d.services ?? []) void ctx.services.install(input, { resolveFrom: d.importMetaUrl }) + ctx.tracing._applyOptions(d.tracing) return () => Promise.resolve(d.setup(ctx)) } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts index 4178d6dd..ee8bdfd1 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts @@ -8,6 +8,7 @@ export interface BuildHubOptions { devframes?: DevframesInput; context?: DevframeHubContext; services?: DevframeServiceInput[]; + tracing?: DevframeTracingOptions; rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations']; configure?: (_: DevframeHubContext) => void | Promise; ui?: DevframeHubUi; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts index 75b0a5ff..f4616b9e 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts @@ -39,6 +39,7 @@ export interface InitHubOptions { base: string; devframes?: DevframesInput; services?: DevframeServiceInput[]; + tracing?: DevframeTracingOptions; rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations']; context?: DevframeHubContext; configure?: (_: DevframeHubContext) => void | Promise; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index d3f0cf78..3762bed7 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -33,6 +33,12 @@ export declare const DEVFRAME_EVENTS: { readonly streamingEnd: "devframe:streaming:end"; readonly streamingUploadCancel: "devframe:streaming:upload-cancel"; }; + readonly sharedState: { + readonly tracingChannels: "devframe:tracing:channels"; + }; + readonly stream: { + readonly tracing: "devframe:tracing"; + }; readonly inPageChannel: { readonly panelStateUpdated: "devframe:in-page:panel-state:updated"; readonly panelStatePatch: "devframe:in-page:panel-state:patch"; diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 9d0ebbbf..55214492 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -162,6 +162,7 @@ export interface DevframeDefinition { build?: boolean; }; services?: DevframeServiceInput[]; + tracing?: DevframeTracingOptions; clientAssets?: StaticAssetsSource; rpc?: DevframeRpcOptions; setup: (_: DevframeNodeContext, _?: DevframeSetupInfo) => void | Promise; @@ -207,6 +208,7 @@ export interface DevframeNodeContext { diagnostics: DevframeDiagnosticsHost; agent: DevframeAgentHost; services: DevframeServicesHost; + tracing: DevframeTracingHost; staticConfig: Partial; scope: { (_: NS): DevframeScopedNodeContext>; @@ -291,9 +293,13 @@ export interface DevframeRpcServerFunctions { name: string; message: string; }) => Promise; + 'devframe:tracing:record': (_: string) => Promise; + 'devframe:tracing:stop': (_: string) => Promise; + 'devframe:tracing:clear': (_: string) => Promise; } export interface DevframeRpcSharedStates { 'devframe:services': DevframeServicesState; + 'devframe:tracing:channels': Record; } export interface DevframeScopedNodeContext = Record> { readonly namespace: NS; @@ -307,6 +313,7 @@ export interface DevframeScopedNodeContext { @@ -386,6 +393,52 @@ export interface DevframeSetupInfo { export interface DevframeSseOptions { route?: string; } +export interface DevframeTraceEvent { + phase: DevframeTracePhase; + at: number; +} +export interface DevframeTraceRecord { + id: string; + channel: string; + startedAt: number; + duration?: number; + status: 'pending' | 'ok' | 'error'; + context: SerializedValue; + result?: SerializedValue; + error?: { + name: string; + message: string; + stack?: string; + }; + events: DevframeTraceEvent[]; +} +export interface DevframeTracingChannelInfo { + name: string; + description?: string; + source: DevframeTracingChannelSource; + recording: boolean; + streamId?: string; + count: number; +} +export interface DevframeTracingChannelInput { + name: string; + description?: string; +} +export interface DevframeTracingHost { + register: (_: string | TracingChannel, _?: { + description?: string; + }) => TracingChannel; + list: () => DevframeTracingChannelInfo[]; + record: (_: string) => void; + stop: (_: string) => void; + records: (_: string) => DevframeTraceRecord[]; + clear: (_: string) => void; + onRecord: (_: string, _: (_: DevframeTraceRecord) => void) => () => void; + _applyOptions: (_: DevframeTracingOptions | undefined) => void; +} +export interface DevframeTracingOptions { + channels?: Array; +} export interface DevframeViewHost { buildStaticDirs: { baseUrl: string; @@ -549,6 +602,8 @@ export type DevframeSnapshotRpcEntry = string | { }; export type DevframeSnapshotRpcInputs = readonly (readonly unknown[])[] | ((_: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise); export type DevframeStorageScope = 'workspace' | 'project' | 'global'; +export type DevframeTracePhase = 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error'; +export type DevframeTracingChannelSource = 'builtin' | 'registered' | 'config' | 'adhoc'; export type McpAuthorization = string | ((_: Request) => boolean | Promise) | false; export type McpSetting = boolean | 'auto' | McpRouteOptions; export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom; @@ -563,6 +618,9 @@ export type ScopedClientFunctions = { [K in keyof DevframeRpc export type ScopedRpcFn = `${NS}:${T}` extends keyof Registry ? Extract : AnyRpcFn; export type ScopedServerFunctions = { [K in keyof DevframeRpcServerFunctions as K extends `${NS}:${infer R}` ? R : never]: DevframeRpcServerFunctions[K]; }; export type ScopedSharedStates = { [K in keyof DevframeRpcSharedStates as K extends `${NS}:${infer R}` ? R : never]: DevframeRpcSharedStates[K]; }; +export type SerializedValue = string | number | boolean | null | SerializedValue[] | { + [key: string]: SerializedValue; +}; export type SettingsForNamespace = NS extends keyof DevframeSettingsRegistry ? DevframeSettingsRegistry[NS] extends Record ? DevframeSettingsRegistry[NS] : Record : Record; export type StaticAssetsSource = string | RemoteAssets; // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 4c08e1e9..36fe0f26 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -371,6 +371,12 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting."; }; + readonly DF0081: { + readonly why: (p: { + runtime: string; + }) => string; + readonly fix: "Run under Node.js 22+ or Bun to record Tracing Channels. Every `ctx.tracing` method is a no-op here."; + }; }, readonly [(d: import("nostics").Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void], never>; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 2604d37f..03a24c28 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -61,6 +61,14 @@ export { DevframeSnapshotRpcEntry } export { DevframeSnapshotRpcInputs } export { DevframeSseOptions } export { DevframeStorageScope } +export { DevframeTraceEvent } +export { DevframeTracePhase } +export { DevframeTraceRecord } +export { DevframeTracingChannelInfo } +export { DevframeTracingChannelInput } +export { DevframeTracingChannelSource } +export { DevframeTracingHost } +export { DevframeTracingOptions } export { DevframeViewHost } export { DevframeWsOptions } export { DevframeWsPeer } @@ -91,6 +99,7 @@ export { ScopedClientFunctions } export { ScopedRpcFn } export { ScopedServerFunctions } export { ScopedSharedStates } +export { SerializedValue } export { SettingsForNamespace } export { StaticAssetsSource } // #endregion \ No newline at end of file