# Data model

> What a log, span, and metric look like inside Fixter, where to see each, and how long data is kept.

Fixter stores three signals (logs, spans, metrics), built for fast time-scoped
search and aggregation. Everything you send over
[OTLP](/setup/opentelemetry/) is normalized into these shapes, and everything
you query reads them. All access is scoped to your workspace.

## Logs

| Field | Notes |
| --- | --- |
| `timestamp` | nanosecond precision |
| `log_id` | server-generated ULID, a stable identity for permalinks and lookups |
| `service` | from `service.name`, the primary grouping dimension |
| `level` | upper-cased, but **not** normalized to a fixed set. See the note below |
| `message` | the log body, indexed for token and substring search |
| `trace_id` / `span_id` / `parent_span_id` | correlation to traces, when present |
| `source_instance_id` | which instance of the service emitted the record |
| `resource` | resource attributes (host, deployment, ...), queryable as `resource.<key>` |
| `payload` | log attributes, queryable directly by attribute name |

Your own attributes are first-class: whatever keys your instrumentation emits become
queryable fields by name, no schema registration needed (see
[dynamic attributes](/reference/querysql/#dynamic-attributes)).

**A note on `level`, because it affects the queries you write.** When your SDK sends a
severity text, Fixter stores that text upper-cased, exactly as sent. Only when severity
text is absent does it derive a level from the OTLP severity number, and then you get
one of `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, or `FATAL`. So `level` holds whatever
your logging library calls things: `CRITICAL`, `SEVERE`, `PANIC`, `ERR`, `WARNING` and
others all occur in practice. Filtering on `level = 'ERROR'` alone can therefore miss
real errors. Prefer a set: `level IN ('ERROR', 'FATAL', 'CRITICAL', 'PANIC')`, matched
to what your services actually emit.

**Where to see logs:** the [Logs page](https://app.fixter.dev/logs) (filter bar, live
tail, SQL mode), the `logs`, `get_log`, and `get_log_neighbors`
[MCP tools](/reference/mcp-tools/#telemetry), or `SELECT ... FROM logs` in
[QuerySQL](/reference/querysql/).

## Spans

| Field | Notes |
| --- | --- |
| `timestamp` | when the span started |
| `duration_ms` | how long it took (fractional milliseconds) |
| `trace_id` / `span_id` / `parent_span_id` | the trace identity triple |
| `service` | the emitting service |
| `name` | the operation (an endpoint, a query, a job step) |
| `kind` | `SERVER`, `CLIENT`, `PRODUCER`, `CONSUMER`, `INTERNAL`, or `UNSPECIFIED` |
| `status_code` | `UNSET`, `OK`, or `ERROR`, with `status_message` for detail |
| `source_instance_id` | which instance emitted the span |
| `resource` / `payload` | resource and span attributes, queryable by name |
| `end_customer` | the `customer_id` span attribute, lifted to a field so you can slice by your own tenant |

Span events and links are stored on the span (exception events land there) but are not
queryable fields in [QuerySQL](/reference/querysql/). Read them through the trace view
or the `get_trace` [MCP tool](/reference/mcp-tools/#telemetry).

Fixter continuously maintains rollups on top of spans, so service-level RED metrics
(request rate, error rate, latency percentiles) answer fast without scanning raw data.

**Where to see spans:** any log with a trace opens its full trace view in the app
(`/traces/<trace-id>`), and the `spans`, `get_trace`, and `aggregate_spans`
[MCP tools](/reference/mcp-tools/#telemetry) or `SELECT ... FROM spans` in
[QuerySQL](/reference/querysql/) query them directly.

## Metrics

| Field | Notes |
| --- | --- |
| `timestamp` | sample time |
| `metric_name` | the metric's name |
| `service` | the emitting service |
| `source_instance_id` | which instance emitted the sample |
| `value` | the scalar value, for gauges and counters |
| `attributes` | metric attributes, queryable by name |

Metrics are stored with the full OTLP model behind these fields: the metric type
(`GAUGE`, `SUM`, `HISTOGRAM`, `EXPONENTIAL_HISTOGRAM`, `SUMMARY`), delta and cumulative
temporality, histogram buckets, summary quantiles, count/sum/min/max, and exemplars
that link a sample back to the trace that produced it.

Those are **not** queryable columns in [QuerySQL](/reference/querysql/), which exposes
the five fields above plus your own attributes. Reach the rest through the
[Metrics page](https://app.fixter.dev/metrics) or the `list_metrics` and `metrics`
[MCP tools](/reference/mcp-tools/#telemetry). A query like `WHERE type = 'HISTOGRAM'` will not
compile.

**Where to see metrics:** the [Metrics page](https://app.fixter.dev/metrics) in the
app, the `list_metrics` and `metrics` [MCP tools](/reference/mcp-tools/#telemetry), or
`SELECT ... FROM metrics` in [QuerySQL](/reference/querysql/).

## Retention

Retention means how far back your data stays accessible for query. It is **two years**
across all three signals. Nothing expires sooner, and you do not manage or tune it.

## Querying beyond the app

When point-and-click runs out, [QuerySQL](/reference/querysql/) (Fixter's read-only
SQL dialect) queries `logs`, `spans`, and `metrics` directly, with monitoring-flavored
functions (latency percentiles, time bucketing, full-text matching) on top of familiar
SQL. It is the same language the Logs page's SQL mode, the `run_sql`
[MCP tool](/reference/mcp-tools/#telemetry), and alert rule conditions all speak.