> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sedata-ai.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom spans & metrics

> Record domain-specific telemetry alongside the auto-instrumentation.

`instrumentServer` returns an `ObservabilityInstance` with helpers for the
three OTel signals you'll most often need: spans, histograms, and counters.

```ts theme={null}
const telemetry = instrumentServer(server, telemetryConfig)
```

## Custom spans

Use `startActiveSpan` for any operation you want timed and traced. The session
id is added automatically.

```ts theme={null}
telemetry.startActiveSpan(
  'cache.lookup',
  { 'cache.key': 'user:42' },
  (span) => {
    const value = cache.get('user:42')
    span.setAttribute('cache.hit', !!value)
    span.end()
    return value
  },
)
```

<Note>
  Always call `span.end()` inside the callback. The helper does not auto-end.
</Note>

### Async spans

```ts theme={null}
await telemetry.startActiveSpan('db.query', { 'db.statement': 'SELECT ...' }, async (span) => {
  try {
    const rows = await db.query('SELECT ...')
    span.setAttribute('db.rows', rows.length)
    return rows
  } catch (err) {
    span.recordException(err as Error)
    span.setStatus({ code: 2, message: (err as Error).message }) // ERROR
    throw err
  } finally {
    span.end()
  }
})
```

### Nested under a tool span

If you call `startActiveSpan` from inside a tool handler, the new span becomes
a child of the auto-generated `tools/call <toolName>` span — you'll see the
nesting in your trace UI.

## Histograms

Use a histogram for distributions you care about — durations, sizes, scores.

```ts theme={null}
const recordLookup = telemetry.getHistogram('cache.lookup.duration', {
  description: 'Cache lookup duration',
  unit: 'ms',
})

const t0 = Date.now()
const value = cache.get(key)
recordLookup(Date.now() - t0, { 'cache.hit': !!value })
```

`getHistogram` returns a recorder function — keep it around (don't recreate
per call) to avoid allocating a new instrument on every record.

## Counters

For monotonically increasing counts:

```ts theme={null}
const incRetry = telemetry.getIncrementCounter('upstream.retry.count', {
  description: 'Upstream retry count',
})

incRetry(1, { upstream: 'github', reason: 'timeout' })
```

## Process attributes through your data processors

If you've configured `dataProcessors`, every attribute set passes through them
before it reaches the exporter. Useful for redacting:

```ts theme={null}
const redactEmails = (data: Record<string, any>) => {
  for (const k of Object.keys(data)) {
    if (typeof data[k] === 'string' && /\S+@\S+/.test(data[k])) {
      data[k] = '[REDACTED:email]'
    }
  }
  return data
}

const config: TelemetryConfig = {
  // ...
  dataProcessors: [redactEmails],
}
```

You can also call `telemetry.processTelemetryAttributes(data)` directly if you
need to apply your processors to a value outside the standard span/metric path.

## Pattern: time a critical path

```ts theme={null}
async function fetchUser(id: string) {
  return telemetry.startActiveSpan('user.fetch', { 'user.id': id }, async (span) => {
    const t0 = Date.now()
    try {
      const user = await db.user.findUnique({ where: { id } })
      span.setAttribute('user.found', !!user)
      return user
    } finally {
      span.setAttribute('user.fetch.duration_ms', Date.now() - t0)
      span.end()
    }
  })
}
```

## Pattern: outcome counter

```ts theme={null}
const outcome = telemetry.getIncrementCounter('agent.handoff.outcome', {
  description: 'Agent handoff outcome',
})

outcome(1, { from: 'router', to: 'researcher', status: 'success' })
```

## Next

<CardGroup cols={2}>
  <Card title="Automatic metrics" icon="gauge" href="/metrics-and-traces/automatic-metrics">
    What's already recorded for free.
  </Card>

  <Card title="Data processors" icon="filter" href="/guides/data-processors">
    Mutate or redact attributes site-wide.
  </Card>
</CardGroup>
