Repository navigation
feat(observability,cli): name the calls behind event-loop stalls, and stream dev events from frontmcp dev - #1013
Conversation
GLOBAL providers can implement onBootstrap(), onReady() and onShutdown().
onBootstrap() runs in dependency order before anything is enumerated and a
throw fails the start with ProviderBootstrapError. onReady() runs once the
server serves, on every entry point. onShutdown() runs in reverse order when a
scope is disposed, each within lifecycle.shutdownHookTimeoutMs (default 3000 ms).
createContainer({ providers, apps }) builds the provider graph of a server
without its registries, flows or transport, and returns get, tryGet and
dispose.
… stdio scope A second caller of runReadyHooks() or runShutdownHooks() returned while the first run was still going, so a concurrent connect() could return before onReady() finished. The stdio SIGTERM handler ran onShutdown() for the primary scope only, so a standalone app's providers were readied but never shut down.
…) providers the config passed
- @frontmcp/edge builds its server with createForGraph() and never ran the providers' onReady(); the
worker and each session Durable Object now run them once, when their first request builds the server.
FrontMcpInstance.emitProvidersReady() is public so a host built from createForGraph() can run them.
- createContainer() registered FrontMcpConfig as { logging } only, so a provider that reads the config
(info, redis via getGlobalStoreConfig) failed or saw nothing; it now gets the object passed.
- The lifecycle e2e tolerates reading the journal while it is rewritten, and the stdio unit spec waits
for the exit instead of a fixed delay.
- create-provider: onReady() is awaited before the entry points return and the signal handlers are
installed; the name match covers third-party values; hooks run once per server; edge and FrontMcpConfig.
- SqliteStorageAdapter: conditional set, delete, incr, deleteIfEquals and
expireIfEquals each run in one BEGIN IMMEDIATE transaction, so they are
atomic across processes; expire and expireIfEquals validate the TTL and
treat an expired key as missing, like the memory and Redis adapters.
DistributedSemaphore now works on SQLite.
- One connection per database file per process, shared by every store on
the file, with a queue that runs the adapter's operations in order and
waits for another process's lock on a timer instead of blocking the
event loop.
- createStorage({ type: 'sqlite', sqlite: { path } }) loads
@frontmcp/storage-sqlite on demand; an invalid sqlite block throws
StorageConfigError whatever the fallback.
- SDK: sqliteStorageProvider() and SqliteStorageToken give providers and
tools a SQLite-backed storage, disconnected at shutdown.
- New e2e app demo-e2e-local-runtime: ten processes contend on one file.
…-provider-lifecycle # Conflicts: # libs/sdk/src/front-mcp/front-mcp.ts
… only once it is configured
…-provider-lifecycle
…cope that holds it Under splitByApp, a server-level useValue provider is held by every app scope, and the first scope to build it ran its onShutdown() when that scope was disposed, before the providers of later scopes that depend on it. Each registry of the tree now counts as a holder: the first runs the bootstrap and ready hooks, and the last to shut down runs onShutdown().
…ue the expiry sweeps - A queued operation's busy timeout counts from its call, restarting when an earlier queued operation gets the lock, so a held lock no longer makes queued operations time out one after another. - SqliteStorageAdapter.disconnect() lets the operations already called finish before closing the file. - The KV and event stores run their periodic sweeps on the connection's queue: no event-loop stall, and no SQLITE_BUSY thrown from the timer while another process holds the lock. - A store whose setup fails releases the shared connection, and a handle closed directly is replaced for the stores opened after it.
…o feat/m02-local-sqlite
A registry that held the same hook-bearing value under two tokens released it at its second token, so onShutdown() ran on it before the providers built between the two tokens that depend on it. A registry now holds each instance once, at its first token. Also complete the multi-instance-cleanup example's feature sentence.
…he server down When one app failed while another was still running a provider's onBootstrap(), the server shut down at once and that provider finished bootstrapping after it, so its onShutdown() never ran. The app registry now waits for every app to settle before it reports the first failure.
…o feat/m02-local-sqlite
ping() queried the database directly with the full busy timeout, so a commit from another process in rollback-journal mode blocked the event loop for up to busyTimeoutMs. It now waits on the queue with timers like every other operation. Also: the e2e worker helper resolves on close so the last stdout lines are read; the lock example takes a unique token and releases it; the createStorage lock snippets set fallback: 'error'; WAL backups use VACUUM INTO.
…n when orphaned Move the CLI's process-tree helpers into a Node-only process module in @frontmcp/utils, with spawnOwned, reapOrphans, watchParent and watchPath. runStdio shuts down through the normal shutdown path when stdin ends or the parent exits (lifecycle.exitWhenOrphaned, on by default for stdio, opt-in for the HTTP and Unix socket servers). CliTaskRunner spawns its workers owned under TASK_WORKER_MARKER so orphaned ones can be reaped.
…/m03-process-kit # Conflicts: # libs/skills/catalog/frontmcp-development/references/create-provider.md
…tstrap Sibling registries that hold the same value shared a holder count but not its onBootstrap() run, so a registry that was not the first holder built and bootstrapped the providers that depend on the value before the value finished bootstrapping. The tree now shares the bootstrap run, and every holder awaits it, so a failure reaches the dependents too.
…ke @frontmcp/utils
The lazy import('@frontmcp/utils') in Scope kept every export of the
barrel in an app's browser bundle, the new process helpers included.
mkdir already loads fs on first use, so a static import is enough.
…o feat/m02-local-sqlite
runStdio attaches the watcher after the providers' onReady() hooks, so a client that closed stdin meanwhile left the server running. Also document the orphan behaviour on runStdio and runUnixSocket.
get() read an expired row and then deleted the key unconditionally, outside a transaction, so a value another process wrote between the two statements was deleted. The purge now deletes the row only if it is still expired. Also: the WAL troubleshooting row no longer suggests walMode: false for an existing WAL file.
…opped, and stop a child without its own group - reapOrphans never stops the calling process, its ancestors or the leader of its process group. A task worker started through `npx tsx` carries the owner tag on its launchers too, so a worker that reaped after its server died stopped its own launchers and their group. - A stopped orphan that its new parent does not collect (a zombie, as under a container's PID 1 without an init) counts as stopped: `kill(pid, 0)` still reaches a zombie, so the reaper waited out the grace period, sent SIGKILL and reported nothing. `ps` now reads the `stat` column. - OwnedProcess.stop() on a child spawned without `detached` signals the child itself and kills it after the grace period. It resolved `true` at once while the child was still running, since such a child leads no process group.
…before shutting down A stdio client that writes its requests and closes stdin (`cat requests.jsonl | node dist/main.js`) lost the reply to any request still running: the orphan watch started the shutdown as soon as stdin ended, and closed the server under it. Now `exitOnShutdownSignals()` tells the shutdown what triggered it, and on `stdin-ended` `runStdio()` first waits, for up to 2 seconds (`STDIO_DRAIN_MS`), until the stdio router has written every reply it owes (`StdioRevisionRouter.whenIdle()`; a cancelled request and a `subscriptions/listen` stream owe none). A signal also stops the orphan watch, so a shutdown already under way logs no second reason.
…till expired ttl() had the same read-then-delete sequence as get(): a value another process wrote between the two statements was deleted. It now uses the guarded purge.
…alone under frontmcp dev
Under frontmcp dev, a server without an observability config got
ObservabilityPlugin.init({ tracing: false, logging: false }) for the dev events
even when it installed the plugin itself. Both records share the plugin class as
their token, so the second replaced the server's own (its tracing and request
logs were gone); a plugin installed on an app stayed, and every call event was
written twice. The events-only plugin is now added only when no plugin named
observability is installed on the server or an app.
…ge's module and harden the offload pool - A worker error is public only when it is a FrontMCP McpError (by its class brand) with isPublic, as on the main thread; an error that only has an isPublic property no longer reaches the client in production. A public one keeps its JSON-RPC code, data and remedy, so a ResourceNotFoundError read in a worker answers -32002 with data.uri. - The worker calls only a function its module exports, never one the exports inherit (toString, call). - captureCallerFile no longer skips node_modules frames: a worker resource declared in an installed package is recorded as that module, not the module that required the package (CommonJS), and is found at all under ESM. - Worker resources are keyed by module, name and URI or URI template, so two of one module or bundle that share a name are not read as each other. - A worker's stdout is forwarded chunk by chunk instead of piped, so eleven or more workers no longer trip a MaxListenersExceededWarning on process.stderr. - A tsx without the cjs/esm registration APIs is skipped in the worker instead of failing every offloaded call. - The read flow loads its worker helper on demand, which takes about 570 bytes off the browser bundle. - The refusal for a resource with no module on the stack says a bundle that inlines @frontmcp/sdk has none. - Catalog: which errors stay public, installed packages and inlined-SDK bundles, preloads in the server's flags, N x maxWorkers threads with splitByApp, the tsx fallback, and a troubleshooting row.
…m11-worker-offload # Conflicts: # apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json # libs/sdk/src/common/metadata/front-mcp.metadata.ts # libs/sdk/src/common/tokens/front-mcp.tokens.ts # libs/sdk/src/common/types/options/index.ts
…lls to it appendFile's mode applies only when it creates the file, so a recording path that already existed kept whatever permissions it had, and recorded calls went into a file others could read. The writer now sets an existing file to 0600 before its first line; a file it cannot do that for is not written to. Also trim slashes in the testing client and transports with trimSlashes and trimTrailing from @frontmcp/utils instead of regexes that backtrack polynomially on runs of '/' (CodeQL js/polynomial-redos).
…m11-worker-offload
…at/m10-stall-dev-events # Conflicts: # apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts # apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json # libs/utils/src/fs/fs.ts
|
@coderabbitai review |
|
Warning Review limit reachedYour organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Next included review available in 27 minutes. View limit details
|
| Layer / File(s) | Summary |
|---|---|
Observability configuration and activation libs/sdk/src/common/types/options/observability/*, libs/observability/src/plugin/*, libs/sdk/src/scope/*, libs/sdk/src/common/interfaces/logger.interface.ts, libs/sdk/src/logger/*, apps/e2e/demo-e2e-dts-consumer/*, libs/skills/catalog/frontmcp-development/references/create-plugin.md |
Adds stall and development-event options, validation, plugin activation, and the shared FRONTMCP_LOGGER token. Scope initialization can infer observability configuration from development-event environment settings when no explicit configuration exists. |
Call tracking and stall reports libs/observability/src/stalls/*, libs/observability/src/__tests__/{call-monitor,in-flight-calls,stall-detector,stall-report}.spec.ts, libs/sdk/src/metrics/* |
Tracks in-flight calls and samples event-loop gaps. Reports include call attribution and GC time; the event-loop stall counter is included in the process metrics category. |
Development-event stream and sinks libs/observability/src/dev-events/*, libs/observability/src/__tests__/{dev-events,dev-events-server}.spec.ts |
Adds event batching, queue limits, JSONL and IPC sinks, and shutdown handling. Server tests cover event output with default, server-installed, and app-installed observability plugins. |
CLI event-file and IPC delivery libs/cli/src/commands/dev/*, libs/cli/src/core/{args,bridge}.ts, libs/cli/src/commands/dev/**/__tests__/* |
Adds --events-file and --no-events, initializes the selected file, and forwards child events from pipe-mode IPC to JSONL output. |
End-to-end verification and documentation apps/e2e/demo-e2e-cli-exec/*, apps/e2e/demo-e2e-standalone/*, libs/skills/catalog/frontmcp-observability/*, libs/skills/catalog/frontmcp-setup/references/setup-project.md, libs/skills/catalog/skills-manifest.json |
Adds E2E coverage for call outcomes, stall attribution, event deduplication, and stdio bridge delivery. Adds stall-detection references and setup guidance. |
Call Recorder Test Synchronization
| Layer / File(s) | Summary |
|---|---|
Condition-based test waits libs/sdk/src/builtin/call-recorder/__tests__/call-recorder.plugin.spec.ts |
Replaces fixed delays and polling with waits for task completion, complete file lines, and warning output. |
Estimated code review effort: 4 (Complex) | ~60 minutes
Sequence Diagram(s)
sequenceDiagram
participant DevCLI
participant ChildServer
participant CallMonitor
participant DevEventStream
participant PipeBridge
participant EventsFile
DevCLI->>ChildServer: configure development-event output
ChildServer->>CallMonitor: execute call hooks
CallMonitor->>DevEventStream: emit call or stall event
DevEventStream->>PipeBridge: send event batch over IPC
PipeBridge->>EventsFile: append JSONL event lines
Merge Risk: 
Moderate · previous review e7748
-
Resolve the unbounded event-write backlog before merging. The startup, shutdown-event, test-reliability, and contract issues also warrant fixes or explicit acceptance.
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 inconclusive Passed checks: 4 passedFull details: Docstring Coverage
-
Explanation
Docstring coverage is 35.48% which is insufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 31 functions across 50 files. (12 skipped: 10 unsupported, 2 over the file limit.)
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
-
Finishing Touches
-
Generate docstrings
-
- Commit to this branch
- Create a new PR
Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Action not completed
|
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T18:09:10.233Z |
…ents # Conflicts: # apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts # apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json # libs/skills/catalog/frontmcp-observability/references/metrics-endpoint.md
…sleeps Test-only fix. The maxBytes spec waited a fixed 50 ms for the first queued write before reading the file, and failed with ENOENT under a loaded parallel run. The call recorder spec now polls (every 10 ms, up to 5 s) for the condition each step needs: the file's first complete line, the maxBytes warning, and the task leaving the working status.
|
Merged e774879 is a test-only fix for a flake in Local runs on e774879 (sdk and observability built with |
…heir file cannot be made, and keep events accepted during shutdown frontmcp dev (and its stdio bridge) failed to start when the events file could not be created, for instance with .frontmcp not writable. It now prints a warning and starts with the server's events turned off. DevEventStream.onShutdown() waited for one drain only, so an event accepted after that drain emptied the queue, but before the stream closed, started a drain the sinks were closed under and was lost. Shutdown now waits until no drain is left before it closes the sinks. Also: the dev-events server spec polls for the first call event instead of a fixed 50 ms wait, the stall e2e allows the 1 ms that whole-millisecond clocks can lose, and the stall docs say there is one detector per thresholdMs.
|
Merged Local runs on 4667cce (sdk and observability built with |
On main's budgets (1,323,819 and 1,330,696), duties grow create() + one tool by 1,309 bytes and create() + WebMcpPlugin + one tool by 1,308, measured against #1013's merged head; the budgets move up by exactly that.
…ne or workspace runs (#1014) * feat(sdk): add @duty, recurring work that one process runs under a lease, with takeover when the holder dies A @duty({ every, scope: 'machine' | 'workspace', leaseMs }) provider has run() called every interval in the one process that holds its lease. The lease is a store key with a TTL: set-if-absent to acquire, compare-and-expire to renew every third of the lease and again before each run, compare-and-delete to release at shutdown. Expiry follows the store's TTL, so another process takes over once a dead or paused holder stops renewing, and a holder that wakes after a takeover never runs again. Leases live in a machine-wide SQLite file, or the server's Redis when configured. Duties start on onReady and stop on shutdown; createContainer() builds them but runs them only with runDuties. * fix(sdk): work out a duty's lease key before opening its store, so a failed key leaves no handle open * fix(sdk): give a duty's lease up once it can't be renewed for a whole lease, and keep it while a stopped run winds down A holder whose store calls kept failing (Redis unreachable, a locked SQLite file, a hung call) kept holding the duty and its run in flight without a confirmed lease, so once the store ended the lease another process could run it at the same time. The holder now arms a monotonic deadline from the moment it sent each successful acquire or renew; past it, it aborts the run in flight, logs that it could not renew within leaseMs and contends again. Acquire anchors that deadline with compare-and-expire instead of reading the key back. stop() stopped renewing before the aborted run returned, so a run that took longer than the rest of the lease to wind down overlapped the next holder's. It now keeps renewing until the run returns, then releases the lease. A takeover waited the last-run marker's TTL rounded up to whole seconds, which for an interval near the timer limit overflowed setTimeout and ran at once; it now waits at most one interval. A process that doesn't get the lease logs once that the duty already runs elsewhere, with the lease key. * fix(sdk): keep duty leases apart and owner-only, and run a container duty's onReady() before it starts - Two duty classes with the same lease in one process (the same name in one server, or two plugins' classes of the same name) left one never running; the second to start now fails with DutyNameConflictError. The same class built more than once (per scope of a splitByApp server) still shares one lease. - Lease keys URI-encode the server and duty names, so a ':' in either can't make two leases one or hit the last-run marker. - createContainer({ runDuties }) needs info.name: without it every unnamed container on the machine shared the 'frontmcp' leases. It now runs each duty's own onReady() before starting it, as a server does. - The lease folder is created 0700 and duties.sqlite 0600 (with an append open, so processes starting together never truncate it); a folder another user owns, or the group or others can write to, or one above it that is not sticky, is refused with UnsafeDutyStoreError. The walk is the Unix-socket guard's, exported as findReplaceableDirectory(). @frontmcp/utils writeFile() takes a flag option for the append open. - The create-duty and create-provider references describe the conflict, the required info, file safety, giving a lease up, clocks, Redis hostnames, splitByApp and the new log lines. * test(e2e): fit the browser bundle budgets to duties @duty's decorator and option schema reach the browser bundle (the runner itself stays out), so create() + one tool grows by 1,309 bytes minified and create() + WebMcpPlugin + one tool by 1,306. The two budgets move up by exactly that, keeping the headroom they had. * test(e2e): fit the browser bundle budgets to duties on top of main's On main's budgets (1,323,819 and 1,330,696), duties grow create() + one tool by 1,309 bytes and create() + WebMcpPlugin + one tool by 1,308, measured against #1013's merged head; the budgets move up by exactly that. * fix(sdk): give a duty runner its holder id in the constructor The cloudflare isolate-safety check (demo-e2e-cloudflare) refuses a randomUUID() it reads as module-eval: the class field initializer of DutyRunner.holder. The id is now set in the constructor, which runs at the same time.
Stacked on #1009
What and why
Proposal items 4.3 (stall attribution) and 5.4 (the
frontmcp devevent stream).The reported problem: when one tool does synchronous work (a large
JSON.parse, a regex on a big string, a sync cryptocall), the event loop stalls and every other call, health check and stream on the server waits. The process-stats
collector already samples the loop delay for
/metrics, but it cannot say what was running during a stall, and itcannot tell a GC pause from a blocking tool. During development there was also no record of how long each call took or
why it failed, other than reading the server's logs.
What this PR adds:
@frontmcp/observability, opt-in withobservability: { stalls: true | { thresholdMs } }(orObservabilityPlugin.init({ stalls })), 200 ms by default, 20 to 60000.onReady) andstopped at shutdown (
onShutdown). A gap ofthresholdMsor more between two checks is a stall, measured to within10 ms. Node.js only: where
perf_hooksis missing the detector does nothing.splitByAppserver, or of standalone apps, share it,so each stall is one warning naming the calls of every server, and is counted once.
Aroundhooks on theexecutestage oftools:call-tool,resources:read-resourceandprompts:get-prompt, while the detector samples: a call stays in flight until itsexecute stage settles,
whatever its outcome (return, throw, abort), so a failed call is never left "running". A call that timed out stays
in flight until the execution it left behind (
state.abandonedExecution) settles. Each call records its method,the entry's full name, its start time and the first 12 hex characters of the session's tracing hash.
PerformanceObserverongcentries. A stall is reported on the sample after theone that measured it, once its GC entries have been delivered. When the stall less its GC time is under the
threshold, the stall is GC's and the calls in flight are not blamed.
CallMonitorlogger, naming at most five calls in flight (method, entry,running time, session hash; never an input, an answer or a raw session id), and one increment of
frontmcp_event_loop_stall_seconds_total{cause, entry}:causeis the call's method,gcorunknown,entryisa registered entry's name, so the label values are bounded by the server. The counter is served under the
processcategory of/metrics.CallMonitorinjects the server logger with the newFRONTMCP_LOGGERtoken of@frontmcp/sdk(
Symbol.for('frontmcp:LOGGER'), the same logger asFrontMcpLogger). The SDKrequires@frontmcp/observability, so a server run as ESM gets the CommonJS copy of the SDK inside the plugin (1.9.3: observability records every failure as SERVER_ERROR in ES-module projects (a second @frontmcp/sdk copy) #802), wherethe
FrontMcpLoggerclass is another token.frontmcp devevent stream.frontmcp devempties.frontmcp/dev-events.jsonlin the project root at start, and the server appends one JSONline per event:
{ type: 'call', method, entry, durationMs, outcome, error?: { type, code }, session? }for eachtools/call,resources/readandprompts/get(reported byFinallyhooks onfinalize, so failures areincluded), and
{ type: 'stall', stallMs, gcMs, cause, calls }for each stall.--events-file <path>namesanother file;
--no-eventsturns it off.frontmcp dev --stdio --servethe server sends the events over its IPC channel(
{ __frontmcp: 'dev-events', events }) and the bridge, its parent, appends them to the file.observability.devEventstakes afile, extrasinks({ name?, write(events), close? })and
maxQueue. Events go through a bounded queue (1000) drained off the call path:emitnever waits, events pastthe bound are dropped with one warning, and a failing sink is logged once and keeps being written to.
frontmcp dev(it setsFRONTMCP_DEV_EVENTS_FILEorFRONTMCP_DEV_EVENTS_IPCon theserver), or with
NODE_ENV=developmentand afileor sink. A server with noobservabilityconfig loads@frontmcp/observabilityunderfrontmcp devfor the events only (tracing and logging off); without the packageit logs one info line saying how to get them. A server, or one of its apps, that installs
ObservabilityPluginitself is left as it is: that plugin writes the events.
stallsanddevEventsare strict Zod schemas. An invalid value throwsObservabilityConfigurationError(OBSERVABILITY_CONFIGURATION, anInternalMcpError) naming each problem, fromObservabilityPlugin.init();@FrontMcp({ observability })now rethrows it, so the server does not start. Anyother failure while setting the plugin up (the package fails to load, tracing or logging cannot be wired) still only
logs a warning, as before.
@frontmcp/utilsgetsappendFile(), used by the events file sink and the bridge.Different from the plan, and why:
frontmcp_event_loop_stall_seconds_total, notfrontmcp_event_loop_stall_seconds: it is acounter, and every counter the repo serves ends in
_total. It carriescausenext toentry, so a tool namedgcand GC time cannot be confused, and tools and resources with the same name stay apart.CallMonitor, not onObservabilityPlugin. The plugin provides it only whenstalls or dev events are on, and the SDK collects hooks from plugin providers, so a server without either gets no
extra hook; each server's hooks run on its own monitor; and the detector and the stream share one object, avoiding
a provider-to-provider dependency between dynamic providers that are built in parallel.
Aroundtracks,Finallyreports. The in-flight registry needs only theAroundhook onexecute: itsnext()rejects when the stage fails, so the hook's ownfinallyends the call. TheFinallyhooks onfinalizereport each call to the dev event stream, including calls that failed before
execute.--servemode, rather than the server writing it and also sending IPC: one writerper mode, and the file survives child restarts as one stream.
monitorEventLoopDelay(). Resetting that histogram betweenthe end of a stall and its next tick drops the stall: a sampler that ran first after a stall lost it, which happened
for about one stall in eight in a loop of blocking tool calls. The gap between two 10 ms samples measures the same
delay, at the same 10 ms resolution, with no reset and with the stall's exact window.
User-facing change
@FrontMcp({ observability: { stalls, devEvents } })andObservabilityPlugin.init({ stalls, devEvents }).@frontmcp/observability:CallMonitor,StallDetector,InFlightCalls,describeStall,countStall,STALL_SECONDS_COUNTER,DevEventStream,JsonlFileSink,IpcDevEventSink,ObservabilityConfigurationError, and the typesStallDetectionOptions,DevEventsOptions,StallReport,StalledCall,CallIdentity,CallMethod,CallRecord,GcPause,StallDetectorOptions,DevEvent,DevCallEvent,DevStallEvent,DevEventSink.@frontmcp/sdk:FRONTMCP_LOGGER(Symbol.for('frontmcp:LOGGER')), the server logger under a tokenthat a plugin package loaded by another copy of the SDK resolves too.
frontmcp_event_loop_stall_seconds_total{cause, entry}, in theprocesscategory of/metrics.Event loop stalled for <n> ms ...(loggerCallMonitor).OBSERVABILITY_CONFIGURATION(ObservabilityConfigurationError), thrown for an invalidobservability.stallsorobservability.devEventsvalue.frontmcp devandfrontmcp dev --stdio: new flags--events-file <path>and--no-events, a new[dev] events: .frontmcp/dev-events.jsonlline, and the.frontmcp/dev-events.jsonlfile.frontmcp dev, a server with noobservabilityconfig and noObservabilityPluginof its own (on theserver or an app) loads
@frontmcp/observabilitywhen it is installed, tracing and logging off, and logs one infoline when it is not.
@frontmcp/utils:appendFile(path, content).frontmcp_nodejs_eventloop_lag_seconds, with nothing naming the callbehind it. After, with
stallson: a warning naming the calls in flight, stall seconds per entry, GC counted apart.frontmcp devkept no record of calls. After: call timings, failures and stalls in.frontmcp/dev-events.jsonl.observabilityconfig never loaded@frontmcp/observability. After, underfrontmcp devonly, with the package installed: it is loaded with tracing, logging and request logs off, so theserver gets the plugin's hooks (no-ops with tracing off) and the dev-event hooks, no spans, no log transport, no
request logs and nothing new on
/metrics;this.telemetryexists in tools and throwsContextExtensionNotAvailableErrorwhen read, as it does for any server withobservability.tracing: false, whereit was
undefined. Outsidefrontmcp devnothing changes.observability: falseor--no-eventsturns theloading off.
ObservabilityPlugin.init()(and@FrontMcp({ observability })) had no option it refused; a failure whilethe SDK set the plugin up from
observabilitywas logged as a warning and the server started without it. After:an invalid
stallsordevEventsvalue throwsObservabilityConfigurationErrorand the server does not start;every other failure while setting the plugin up still only warns.
section, the metric), https://frontmcp.dev/reference/cli#frontmcp-dev (dev events,
--events-file,--no-events),the
/metricspage (new counter).frontmcp-observability: new referencereferences/stall-detection.mdand exampleexamples/stall-detection/find-a-blocking-tool.md;SKILL.md(routing tables, examples, description andtriggers);
references/metrics-endpoint.md(the stall counter);skills-manifest.json.frontmcp-setup:references/setup-project.md, new "Dev events" section underfrontmcp dev.frontmcp-development:references/create-plugin.md, injecting the server logger withFRONTMCP_LOGGER.Tests
@frontmcp/observability:in-flight-calls.spec.ts(success, throw, abort, a timed-out execution that settleslater or rejects, overlap and pruning),
stall-detector.spec.ts(fake clock and fake GC feed: under thethreshold, a call running through a stall, a call that blocked and ended just before the sample, a call that ended
before the stall and one that started after it, GC taking up a stall, a partial GC overlap, the 10 ms interval and
stop, a runtime without
perf_hooks, a throwing listener, the realperf_hooksdefaults; and, on the real eventloop, a stall the sampler is the first to see after it ends),
stall-report.spec.ts(the log line, the five-call bound, the metric labels),dev-events.spec.ts(batching, aslow sink and the queue bound, a failing sink logged once, shutdown, the JSONL file sink, the IPC sink),
observability-options.spec.ts(defaults, dev-only resolution, every validation message,init()failing),call-monitor.spec.ts(the provider and itsFRONTMCP_LOGGERinjection, the declared hooks, tracking per flow,one detector shared by the servers of a process, call events with no input or answer, a stall as a warning, a
metric and a dev event, start and stop),
dev-events-server.spec.ts(a real server underFRONTMCP_DEV_EVENTS_FILE: call events with noobservabilityconfig; anObservabilityPluginthe server or anapp installs is kept, its request log still written, each call event written once).
dev-events-observability.spec.ts(the configfrontmcp devimplies, none when the server or an appinstalls
ObservabilityPlugin, the not-installed message, the schema keepsstallsanddevEvents),logger-token.spec.ts(FRONTMCP_LOGGERis the server logger, underSymbol.for('frontmcp:LOGGER')),observability-scope.helper.spec.ts(a configuration error fails the boot, another failure still warns),metrics.service.spec.ts(the stall counter underprocess).resolve-dev-launch.spec.ts(default file,--events-filefrom a subfolder, IPC for--serve,--no-eventsclearing an inherited value),dev-events-file.spec.ts,upstream-client.spec.ts(dev eventsfrom the IPC channel),
bridge.spec.ts(flag mapping).@frontmcp/utils:appendFile.demo-e2e-standalone/e2e/stall-attribution.e2e.spec.ts: a busy-waiting tool is named in the stall warning (withits session hash), and a call that failed before it is not. Checked against a build whose registry ends a call
only on success: the test fails, naming the failed call.
demo-e2e-cli-exec/e2e/cli-dev-events.e2e.spec.ts:frontmcp devwrites call timings, a failure and a stall to.frontmcp/dev-events.jsonl;frontmcp dev --stdio --servegets the same events over IPC into--events-file; a server that installsObservabilityPluginitself keeps it underfrontmcp dev(its requestlog is written) and each call event is written once. Checked against a build that adds the events-only plugin
anyway: the test fails, with no request log.
demo-e2e-dts-consumer:stalls-dev-events-must-compile.tstypesobservability.stallsanddevEvents, and aprovider injecting
FRONTMCP_LOGGER.perf_hooks, beforeonReady, after shutdown) wasnever pruned; the monitor now tracks calls only while the detector samples (
StallDetector.running).monitorEventLoopDelay()histogram before its next tick dropped the stall that just ended (about onein eight in a loop of blocking calls, so the stall e2e could flake); the detector now times its own samples.
frontmcp dev, the implied plugin replaced anObservabilityPlugin.init()the server installed itself(same plugin class, so the second record won: its tracing and request logs were gone), and duplicated every call
event when an app installed one; the implied plugin is now skipped in both cases.
stallson, nor underfrontmcp devwith@frontmcp/observabilityinstalled (
logger.child is not a function): theFrontMcpLoggerclassCallMonitorinjected belonged to theplugin's CommonJS copy of the SDK. Checked by hand with an
.mtsserver run bytsx: it now starts and writesits call events.
splitByAppserver, or of a standalone app, ran its own detector: every stall was reported onceper server, the others saying
with no call in flight, andgcandunknownwere counted once per server. Thedetector is now one per process.
@frontmcp/sdk7644,frontmcp(cli) 2092,@frontmcp/utils2000,@frontmcp/observability556,@frontmcp/skills249, all passing.nx test demo-e2e-standalone54/54,nx test demo-e2e-cli-exec201/201,nx test demo-e2e-dts-consumer17/17.
scripts/fix-unused-imports.mjs, prettier, andtsc --noEmitonthe lib and spec tsconfigs of
sdk,observability,cliandutils.Summary by CodeRabbit
frontmcp devnow records call outcomes and enabled stall events to.frontmcp/dev-events.jsonlby default. Use--events-fileto choose another location or--no-eventsto disable recording.--stdio --servemode and can be configured with custom sinks.