Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export default defineAppConfig({
'/guide/json-render',
'/guide/diagnostics',
'/guide/streaming',
'/guide/tracing-channels',
],
},
{
Expand Down
107 changes: 107 additions & 0 deletions docs/content/1.guide/24.tracing-channels.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions docs/content/6.errors/DF0081.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/content/8.references/1.terms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/content/8.references/3.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions docs/content/8.references/4.node-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>` | **Required.** Server-side entry point, run in every runtime. Optional 2nd arg carries runtime metadata, notably parsed CLI `flags` under `createCac`. |
Expand Down Expand Up @@ -130,6 +131,20 @@ The methods on `ctx.services`: [Cross-Devframe Services](/guide/services#the-dev
| `install` | `(input, options?) => Promise<api \| undefined>` | 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<void>` | **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).
Expand Down
2 changes: 1 addition & 1 deletion docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down
4 changes: 2 additions & 2 deletions packages/agentic/src/mcp/__tests__/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } })
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/adapters/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/adapters/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
15 changes: 15 additions & 0 deletions packages/devframe/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading