content.perf

The lifecycle timing recorder — debug timelines or OpenTelemetry forwarding via the tracing plugins.

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-side handler() responses also carry a Server-Timing header.
  • OpenTelemetry — install tracingOtel({ tracer }): spans are forwarded to OTel, with no logs and no Server-Timing.

Type: Perf

Every top-level callinit(), get(), list(), navigation(), and plugin methods like query() / search() — auto-prints a timeline of everything that ran inside it:

debug.ts
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.6ms

Rows 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

MethodDescription
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.

Files are parsed concurrently, so an operation's 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

NameWhat it times
initThe whole initialization.
source:loadLoading one source (keys + parse + cache).
source:keysListing a source's keys (filesystem walk, GitHub API call, …).
source:readOne 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>:partialOne 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:setOne cache read / write.
cache:refreshRe-parsing a source and reconciling the cache.
artifact:build / artifact:read:manifestBuilding / restoring a compressed artifact (gzip + digest).
snapshot:loadHydrating a source from a snapshot artifact.
schema:inferInferring a source's JSON schema.
get / list / navigation / initOne top-level call end-to-end (wall-clock).
navigation:generateBuilding the navigation tree.
query / query:sql / query:build-indexA sqlQuery execution, its SQL, and index building.
search / search:indexA 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:

NameWhat it times
comark:autocloseAuto-closing incomplete markdown syntax.
comark:pre:<plugin>A comark plugin's pre hook (e.g. comark:pre:frontmatter).
comark:tokenizeThe core markdown tokenizer.
comark:nodesToken → 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.0

Note 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' },
    })
  })
}