Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
847 changes: 24 additions & 823 deletions apps/website/basehub-types.d.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/website/components/mdx-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { MDXComponents } from "mdx/types";
import { TerminalPrompt } from "./terminal/terminal-prompt";
import { TerminalTabs } from "./terminal/terminal-tabs";
import { LogLine } from "./terminal/log-line";
import { ConceptBoxes, ConceptBox } from "./concept-boxes";
import { Callout } from "./ui/callout";
import { Video } from "./video";
Expand All @@ -14,6 +15,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
...defaultMdxComponents,
TerminalPrompt,
TerminalTabs,
LogLine,
ConceptBoxes,
ConceptBox,
Callout,
Expand Down
121 changes: 121 additions & 0 deletions apps/website/components/terminal/log-line.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";

import { cn } from "../../utils/cn";

interface LogLineProps {
children: string;
className?: string;
}

type Segment = { text: string; color: string };

/**
* Parses an xmcp observability log line into colored segments.
*
* Expected format:
* <timestamp> <LEVEL> <event.action> <type/name> req=<id> dur=<ms> outcome=<result> | {json}
*
* Colors match the ANSI output from the runtime:
* - timestamp: dim gray
* - INFO: cyan bold, ERROR: red bold
* - event.action: blue
* - type/name: magenta
* - req value: yellow
* - dur value: green (<250), yellow (250-999), red (>=1000)
* - outcome value: green (success), red (failure), gray (-)
* - pipe + JSON: gray
*/
function parseLogLine(line: string): Segment[] {
const pipeIdx = line.indexOf(" | ");
const prefix = pipeIdx !== -1 ? line.slice(0, pipeIdx) : line;
const json = pipeIdx !== -1 ? line.slice(pipeIdx) : "";

const parts = prefix.split(" ");
const segments: Segment[] = [];

for (let i = 0; i < parts.length; i++) {
if (i > 0) segments.push({ text: " ", color: "" });

const part = parts[i];

if (i === 0) {
// timestamp
segments.push({ text: part, color: "text-gray-500" });
} else if (part === "INFO") {
segments.push({ text: part, color: "text-cyan-400 font-bold" });
} else if (part === "ERROR") {
segments.push({ text: part, color: "text-red-400 font-bold" });
} else if (part.includes(".start") || part.includes(".end")) {
// event.action
segments.push({ text: part, color: "text-blue-400" });
} else if (part.includes("/")) {
// type/name
segments.push({ text: part, color: "text-purple-400" });
} else if (part.startsWith("req=")) {
const [label, value] = splitKV(part);
segments.push({ text: label, color: "text-gray-500" });
segments.push({ text: value, color: "text-yellow-400" });
} else if (part.startsWith("dur=")) {
const [label, value] = splitKV(part);
segments.push({ text: label, color: "text-gray-500" });
segments.push({ text: value, color: durationColor(value) });
} else if (part.startsWith("outcome=")) {
const [label, value] = splitKV(part);
segments.push({ text: label, color: "text-gray-500" });
segments.push({ text: value, color: outcomeColor(value) });
} else {
segments.push({ text: part, color: "text-white" });
}
}

if (json) {
segments.push({ text: json, color: "text-gray-600" });
}

return segments;
}

function splitKV(part: string): [string, string] {
const eqIdx = part.indexOf("=");
return [part.slice(0, eqIdx + 1), part.slice(eqIdx + 1)];
}

function durationColor(value: string): string {
if (value === "-") return "text-gray-500";
const num = parseInt(value, 10);
if (isNaN(num)) return "text-white";
if (num >= 1000) return "text-red-400";
if (num >= 250) return "text-yellow-400";
return "text-green-400";
}

function outcomeColor(value: string): string {
if (value === "success") return "text-green-400";
if (value === "failure") return "text-red-400";
return "text-gray-500";
}

export function LogLine({ children, className }: LogLineProps) {
const lines = children.trim().split("\n");

return (
<div className={cn("relative group", className)}>
<div className="p-3 px-4 bg-black border border-white/20 overflow-x-auto">
<pre className="font-mono text-sm whitespace-pre">
<code>
{lines.map((line, lineIdx) => (
<span key={lineIdx}>
{lineIdx > 0 && "\n"}
{parseLogLine(line).map((seg, segIdx) => (
<span key={segIdx} className={seg.color}>
{seg.text}
</span>
))}
</span>
))}
</code>
</pre>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions apps/website/content/docs/configuration/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"icon": "RocketLaunchIcon",
"pages": [
"transports",
"observability",
"custom-directories",
"middlewares",
"server-info",
Expand Down
165 changes: 165 additions & 0 deletions apps/website/content/docs/configuration/observability.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
---
title: "Observability"
metadataTitle: "Observability | xmcp Documentation"
publishedAt: "2026-02-23"
summary: "Understand xmcp execution logs, terminal formatting, correlation, and redaction."
description: "Enable and use standardized observability logs for tools, prompts, and resources."
---

## Enable observability

Enable runtime execution logs with a single flag:

```typescript title="xmcp.config.ts"
const config: XmcpConfig = {
observability: true,
};

export default config;
```

When enabled, xmcp logs execution events for tools, prompts, and resources to `stderr`.

You can also use the object form for advanced behavior:

```typescript title="xmcp.config.ts"
const config: XmcpConfig = {
observability: {
enabled: true,
stderr: true,
color: "auto",
redaction: {
extraSensitiveKeys: ["sessionToken"],
allowedKeys: [],
},
},
};

export default config;
```

## Observability options reference

| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` (object form) | Enables/disables observability events. |
| `stderr` | `boolean` | `true` | Keeps local terminal debugging output. |
| `color` | `"auto" \| "on" \| "off"` | `"auto"` | Controls ANSI color on the terminal prefix (`auto` = TTY-aware). |
| `redaction.extraSensitiveKeys` | `string[]` | `[]` | Additional key-name matches to redact. |
| `redaction.allowedKeys` | `string[]` | `[]` | Explicit key-name allowlist (no redaction). |

## Output format

Each line includes a human-friendly terminal prefix plus a parseable JSON payload:

<LogLine>
{`2026-02-23T20:05:05.396Z INFO tool.start tool/get-weather req=4 dur=- outcome=- | {"@timestamp":"2026-02-23T20:05:05.396Z","event.action":"tool.start","log.level":"info",...}
2026-02-23T20:05:05.399Z INFO tool.end tool/get-weather req=4 dur=3 outcome=success | {"@timestamp":"2026-02-23T20:05:05.399Z","event.action":"tool.end","event.duration":3000000,"event.outcome":"success",...}`}
</LogLine>

- Left side: optimized for live terminal scanning.
- Left side may include ANSI colors for readability when `observability.color` is enabled (`auto` by default).
- Right side (`| {json}`): optimized for ingestion and tooling.
- JSON payload remains uncolored for copy/paste and parser compatibility.

### Color mode and environment overrides

Color behavior can be controlled in config and overridden at runtime:

- `observability.color = "auto"` uses TTY-aware detection.
- `observability.color = "on"` forces colorized prefixes.
- `observability.color = "off"` disables colorized prefixes.
- `XMCP_OBSERVABILITY_COLOR=auto|on|off` overrides config at runtime.
- `NO_COLOR` disables color in `auto` mode.
- `FORCE_COLOR` enables color in `auto` mode.

## Standards alignment

The current log shape intentionally follows common observability standards so logs can move into existing pipelines without custom adapters:

- **OpenTelemetry-style event semantics**: `event.action`, `event.outcome`, `event.duration`
- **ECS-compatible fields**: `@timestamp`, `log.level`, `trace.id`, `span.id`
- **W3C Trace Context correlation**: `traceparent` header parsing for trace/span IDs

This improves interoperability with typical log backends, dashboards, and tracing workflows.

## Shipping logs to external systems

Since xmcp emits structured JSON to `stderr`, you can ship logs to any external system using standard log collectors — no custom sinks or adapters needed.

The general pattern is:

```
xmcp server (stderr) → log collector → log backend → dashboard
```

### Example: Grafana via Loki + Promtail

The [`observability-grafana`](https://github.com/nichochar/xmcp/tree/main/examples/observability-grafana) example provides a full Docker Compose setup with xmcp, Promtail, Loki, and Grafana.

Set `color: "off"` in your config to keep stderr output clean for machine parsing:

```typescript title="xmcp.config.ts"
const config: XmcpConfig = {
observability: {
enabled: true,
color: "off",
},
};

export default config;
```

Promtail extracts the JSON payload after the `|` delimiter and pushes it to Loki. You can then query with LogQL:

```text
# All xmcp logs
{job="xmcp"}

# Failed tool executions
{job="xmcp", outcome="failure"}

# Slow tools (> 500ms)
{job="xmcp", phase="end"} | json | durationMs > 500

# Trace correlation
{job="xmcp"} | json | trace_id="<your-trace-id>"
```

### Other collectors

The same approach works with any log collector that can read `stderr` or a log file:

- **Grafana Alloy** — `loki.source.file` component
- **Fluentd / Fluent Bit** — tail input with JSON parser
- **Vector** — `file` source with `json_parser` transform
- **Docker log drivers** — route container stderr to CloudWatch, Datadog, or Loki directly
- **OpenTelemetry Collector** — `filelog` receiver

Since the JSON payload includes `trace.id` and `span.id` fields, collectors that support trace correlation can link logs to distributed traces automatically.

## Key fields

| Field | Purpose |
| --- | --- |
| `@timestamp` | Canonical event time |
| `event.action` | Lifecycle action (`tool.start`, `tool.end`, etc.) |
| `log.level` | Severity (`info` / `error`) |
| `event.outcome` | Success/failure on end events |
| `event.duration` | Duration in nanoseconds |
| `durationMs` | Duration in milliseconds (human-readable compatibility) |
| `request.id` | Request correlation ID |
| `trace.id`, `span.id` | Distributed trace correlation (when `traceparent` is present) |

## Redaction defaults

xmcp redacts common sensitive keys recursively in logged input/output summary by default, including:

- `authorization`
- `token`, `access_token`, `refresh_token`
- `password`
- `secret`
- `apiKey` / `api_key`
- `cookie`, `set-cookie`

You can add custom keys through `observability.redaction.extraSensitiveKeys`, and preserve specific keys with `observability.redaction.allowedKeys`.
37 changes: 37 additions & 0 deletions apps/website/content/docs/configuration/transports.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,40 @@ This is useful when your tools or dependencies contain debug logs that would oth
## Troubleshooting

Keep in mind that clients like Claude Desktop are not compatible with STDIO logging and would cause a JSON parsing error. You can use the `silent` option to redirect console output to stderr and avoid this issue.

## Observability

Enable execution observability logs for tools, prompts, and resources:

```typescript title="xmcp.config.ts"
const config: XmcpConfig = {
observability: {
enabled: true,
stderr: true,
color: "auto",
sinks: [
{
type: "datadog",
apiKey: process.env.DATADOG_API_KEY ?? "",
site: "us1",
},
],
},
};
```

When enabled, xmcp emits structured JSON logs to `stderr` for each execution start/end, including duration and success/failure details.
The terminal prefix can be colorized for readability (`observability.color`), while the JSON payload remains plain and parseable.

Example output:

```text
2026-02-23T20:05:05.396Z INFO tool.start tool/structured-content req=4 dur=- outcome=- | {"@timestamp":"2026-02-23T20:05:05.396Z","event.action":"tool.start","log.level":"info",...}
2026-02-23T20:05:05.399Z INFO tool.end tool/structured-content req=4 dur=3 outcome=success | {"@timestamp":"2026-02-23T20:05:05.399Z","event.action":"tool.end","event.duration":3000000,"event.outcome":"success",...}
```

Observability logs redact common sensitive keys recursively by default (`authorization`, `token`, `password`, `secret`, `apiKey`, `cookie`, etc.).

If a `traceparent` header is present, xmcp also includes `trace.id` and `span.id` for cross-service correlation.

For full details on format, standards alignment, and parsing tips, see the [Observability](./observability) page.
35 changes: 35 additions & 0 deletions examples/observability-grafana/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
FROM node:20-slim AS build

RUN corepack enable && corepack prepare pnpm@latest --activate

WORKDIR /app

# Copy root workspace files
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY packages/xmcp/package.json packages/xmcp/
COPY examples/observability-grafana/package.json examples/observability-grafana/

RUN pnpm install --frozen-lockfile

COPY packages/xmcp/ packages/xmcp/
COPY examples/observability-grafana/ examples/observability-grafana/

RUN pnpm --filter "xmcp" build
RUN pnpm --filter "Observability Grafana" build

FROM node:20-slim

WORKDIR /app

COPY --from=build /app/examples/observability-grafana/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/examples/observability-grafana/node_modules ./example_modules
COPY --from=build /app/packages/xmcp ./packages/xmcp

RUN mkdir -p /var/log/xmcp

EXPOSE 3002

# Start the server, redirect stderr to a log file that promtail reads
# Use unbuffer-like approach to avoid buffering issues
CMD ["sh", "-c", "node dist/http.js 2> >(tee /var/log/xmcp/server.log >&2)"]
Loading
Loading