---
title: "content.perf"
description: "The lifecycle timing recorder — debug timelines or OpenTelemetry forwarding via the tracing plugins."
canonical_url: "https://content.comark.dev/reference/content/perf"
---
# content.perf

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

## `content.perf` {#contentperf}

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](https://content.comark.dev/plugins/built-in/tracing):

- **No-op (default)** — every method does nothing, so instrumented code paths cost nothing in production.
- **Debug** — install [`tracingDebug()`](https://content.comark.dev/plugins/built-in/tracing#debug-timelines): timelines are logged for every top-level call. Server-side [`handler()`](https://content.comark.dev/reference/content/handler) responses also carry a `Server-Timing` header.
- **OpenTelemetry** — install [`tracingOtel({ tracer })`](https://content.comark.dev/plugins/built-in/tracing#opentelemetry): spans are forwarded to OTel, with no logs and no `Server-Timing`.

**Type:** [`Perf`](https://content.comark.dev/reference/types/content#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:

```ts [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()`](https://content.comark.dev/plugins/built-in/tracing#debug-timelines) plugin — it works on the server and in the browser:

```ts
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()`](https://content.comark.dev/plugins/built-in/tracing#opentelemetry) plugin:

```ts
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: 'docs-site' })

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

`content.perf` exposes the following 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`](https://comark.dev) — run sync or async `fn` inside an active span.                                                   |
| `perf.entries()`                       | The raw [`PerfEntry`](https://content.comark.dev/reference/types/content#perf) 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.

::note
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 (for example 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

The recorder times the following operations:

| 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, forge tree listing, …).                                                                                                                                                                                  |
| `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 (for example `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 (for example `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`](https://content.comark.dev/plugins/built-in/sql-query) execution, its SQL, and index building.                                                                                                                                      |
| `search` / `search:index`                   | A [full-text search](https://content.comark.dev/plugins/built-in/full-text-search) execution and its index building.                                                                                                                               |
| `comark:*`                                  | Phases inside the markdown engine — see [Inside the parse](#inside-the-parse-comark-spans).                                                                                                                                                        |

## Inside the parse: comark spans

The markdown plugin shares `content.perf` with [comark](https://comark.dev)'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. Only recorded when `autoClose` is enabled.                                                 |
| `comark:pre:<plugin>`  | A comark plugin's `pre` hook (for example `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()`](https://content.comark.dev/reference/content/handler) responses include a [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/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:

```ts
import { withSpan } from 'comark/utils/trace'

// In a plugin's setup(content):
async function rebuild() {
  return content.perf.run('search-index:rebuild', async () => {
    await withSpan(content.perf, 'search-index:build', () => buildIndex())
    await withSpan(content.perf, 'search-index:persist', () => persist(), {
      attributes: { source: 'default' },
    })
  })
}
```


## Sitemap

See the full [sitemap](https://content.comark.dev/sitemap.md) for all pages.
