content.perf
content.perf
The lifecycle timing recorder. It records how long each internal operation takes (source loading, per-file parsing, hook dispatch, cache reads/writes) so you can see where time goes. It runs in one of three modes, selected by the tracing plugins:
- No-op (default) — every method does nothing, so instrumented code paths cost nothing in production.
- Debug — install
tracingDebug(): timelines are logged for every top-level call. Server-sidehandler()responses also carry aServer-Timingheader. - OpenTelemetry — install
tracingOtel({ tracer }): spans are forwarded to OTel, with no logs and noServer-Timing.
Type: Perf
Every top-level call — init(), get(), list(), navigation(), and plugin methods like query() / search() — auto-prints a timeline of everything that ran inside it:
import tracingDebug from 'comark-content/plugins/tracing/debug'
const content = comarkContent({ source: fs('./content'), plugins: [tracingDebug()] })
await content.navigation()
// navigation timeline (31.3ms)
// navigation ██████████████████████████████████████████████████ 31.3ms
// list ██████████████████████████████ 18.7ms
// init ██████████████████████████████ 18.6ms
// source:load ████████████████████████████ 17.7ms
// source:keys ████ 2.5ms
// parse:.md:partial ×85 ███████████████████████ 14.2ms (953.2ms cpu, avg 11.21ms)
// hook:file:parsed ×85 █████████ 5.9ms (0.1ms cpu, avg 0.00ms)
// schema:infer █ 0.5ms
// cache:set █ 0.3ms
// navigation:generate ████████████████████ 12.6ms
await content.get('/about')
// get timeline (16.9ms)
// get ██████████████████████████████████████████████████ 16.9ms
// cache:get █ 0.0ms
// parse:.md █████████████████████████████████████████████████ 16.6ms
// hook:file:parsed █ 0.0ms
// cache:set █ 0.1ms
await content.get('/about') // now served from the cache
// get timeline (0.7ms)
// get ██████████████████████████████████████████████████ 0.7ms
// cache:get ███████████████████████████████████████████████ 0.6msRows are nested by parent span (indented); bars show start offset and wall-clock duration. Overlapping spans with the same name (like the 85 concurrent per-file reads and parses during init) are collapsed across async branches into one ×N row. Nested top-level calls don't print their own timeline — the outermost call prints the single combined one.
For aggregate numbers across all calls, content.perf.report() logs a per-operation table (count / total / avg / max), and content.perf.timeline() logs one timeline of everything recorded so far.
Enabling debug mode
Install the tracingDebug() plugin — it works on the server and in the browser:
import tracingDebug from 'comark-content/plugins/tracing/debug'
const content = comarkContent({ source, plugins: [tracingDebug()] })The plugin is always active when installed; gate it yourself for conditional debugging — falsy entries in the plugins array are skipped, so plugins: [process.env.COMARK_PERF_DEBUG && tracingDebug()] works. Browser debug mode records and logs timelines but does not add Server-Timing headers.
OpenTelemetry (Vercel Tracing)
Pass content.perf (or an OpenTelemetry tracer) to createMarkdownParser({ tracer }) — both implement ComarkTracer (startSpan / startActiveSpan), the same API comark uses internally.
For Vercel / OTel export, register the SDK in the host app and install the tracingOtel() plugin:
import { trace } from '@opentelemetry/api'
import { registerOTel } from '@vercel/otel'
import tracingOtel from 'comark-content/plugins/tracing/otel'
// Once per process — Next.js: instrumentation.ts; Nuxt on Vercel: instrumentation.ts at project root
registerOTel({ serviceName: 'my-app' })
const content = comarkContent({
source: fs('./content'),
plugins: [tracingOtel({ tracer: trace.getTracer('comark-content') })],
})With the OTel plugin, spans go to OpenTelemetry only — no in-memory entries, timelines, or Server-Timing. Only one tracing plugin can be active per Content instance: the first one in the plugins array wins.
Lifecycle spans (init, get, parse:.md, cache:get, …) then appear as custom spans in Session Tracing and Trace Drains.
Methods
| Method | Description |
|---|---|
perf.startSpan(name, options?) | Start an inactive span; call .end() when done. |
perf.startActiveSpan(name, fn) | Run fn(span) as an active span (children nest under it). |
perf.run(name, fn, options?) | Like withSpan, but when it's the outermost run, auto-prints a debug timeline of everything recorded while it ran. Used by the core read methods. |
withSpan(tracer, name, fn, options?) | Helper from comark/utils/trace — run sync or async fn inside an active span. |
perf.entries() | The raw PerfEntry list recorded so far. |
perf.report() | Log the aggregated table (count / total / avg / max per operation), sorted by total time. |
perf.timeline(options?) | Log the waterfall timeline in call order; concurrent same-name spans collapse into ×N rows. options.width sets the bar width (default 50). |
perf.reset() | Clear recorded entries. |
perf.startScope() | Collect entries recorded until scope.end() — used internally for per-request Server-Timing. Debug-only: returns undefined in no-op and OTel modes. |
Each top-level call prints its timeline automatically; call perf.report() for the aggregated table across all calls, or perf.timeline() for one combined timeline of everything recorded so far.
total(ms) is the sum of overlapping spans and can far exceed the wall-clock time of the phase that contains it (e.g. 48 parallel parse:.md:partial spans of ~2ms each sum to ~96ms inside a ~40ms init). Compare totals between operations to find bottlenecks; use the init/get spans for actual wall-clock time.Operation names
| Name | What it times |
|---|---|
init | The whole initialization. |
source:load | Loading one source (keys + parse + cache). |
source:keys | Listing a source's keys (filesystem walk, GitHub API call, …). |
source:read | One raw file read from the source (source.getItem) — a disk read or a network fetch for remote sources. Parsers read through this, so parse:<ext> spans include their source:read time; compare the two to separate I/O from parsing cost. |
parse:<ext> | One full body parse for that extension (e.g. parse:.md), triggered by get() or init({ partial: false }). meta carries the file key. |
parse:<ext>:partial | One frontmatter-only parse — what the default (partial) init() does for every file. Much cheaper than a full parse; the body is not touched. |
hook:<name> | One dispatch of that hook and all its handlers (e.g. hook:file:parsed covers schema-validation, markdown-fields, …). Hooks with no registered handlers are not timed. |
cache:get / cache:set | One cache read / write. |
cache:refresh | Re-parsing a source and reconciling the cache. |
artifact:build / artifact:read:manifest | Building / restoring a compressed artifact (gzip + digest). |
snapshot:load | Hydrating a source from a snapshot artifact. |
schema:infer | Inferring a source's JSON schema. |
get / list / navigation / init | One top-level call end-to-end (wall-clock). |
navigation:generate | Building the navigation tree. |
query / query:sql / query:build-index | A sqlQuery execution, its SQL, and index building. |
search / search:index | A full-text search execution and its index building. |
comark:* | Phases inside the markdown engine — see Inside the parse. |
Inside the parse: comark spans
The markdown plugin shares content.perf with comark's parser (its ParserOptions.tracer accepts the same recorder), so a full-body parse breaks down into engine-level spans inside parse:.md:
| Name | What it times |
|---|---|
comark:autoclose | Auto-closing incomplete markdown syntax. |
comark:pre:<plugin> | A comark plugin's pre hook (e.g. comark:pre:frontmatter). |
comark:tokenize | The core markdown tokenizer. |
comark:nodes | Token → AST conversion and unwrapping. |
comark:post:<plugin> | A comark plugin's post hook — where plugins like highlight or toc do their heavy work, usually the real cost of a slow parse. |
If you pass your own tracer in the markdown option (markdown: { comark: { tracer } }), it takes precedence over content.perf.
Server-Timing header
In debug mode, handler() responses include a Server-Timing header summing the spans recorded while serving the request, plus a total metric. Open the browser devtools Network panel and select a /api/content/** request to see the breakdown rendered as a timing chart.
Server-Timing: get;dur=3.2, cache-get;dur=0.4, parse-.md;dur=2.1, total;dur=4.0Note that operation names are sanitized for the header (: becomes -), and that concurrent requests sharing the instance may attribute overlapping spans to each other — treat the header as a debug aid, not a precise trace.
Custom spans
startSpan / startActiveSpan / withSpan are public, so plugins or app code can add their own timings. Wrap a plugin's public method in run() to get the same auto-printed per-call timeline as the core methods, and use withSpan() for the steps inside it:
import { withSpan } from 'comark/utils/trace'
// In a plugin's setup(content):
async function rebuild() {
return content.perf.run('my-plugin:rebuild', async () => {
await withSpan(content.perf, 'my-plugin:index', () => buildIndex())
await withSpan(content.perf, 'my-plugin:persist', () => persist(), {
attributes: { source: 'default' },
})
})
}