Alpha

Wire contract

The wire format every WizeLens add-on, the hub, and the cloud follow: headers, endpoints, types, storage, and rules.

Every add-on (.NET, Node, browser) MUST follow this. The UI depends only on this contract.

Base path

/_wizelens (configurable). All paths below are relative to it.

HTTP headers

Header Direction Meaning
traceparent request W3C trace context. Honor on incoming, send on outgoing.
X-WizeLens-Trace response Trace id (32 hex) of the request that produced this response.
X-WizeLens-Endpoint response Absolute URL of this service's wizelens base, e.g. http://localhost:5200/_wizelens.
X-WizeLens-Hop request (federation) Integer hop count. Stop federating when > 5.
Server-Timing response Appended: wl-trace;desc={traceId}, wl-endpoint;desc="{endpoint}". The page reads it from the navigation entry, so the page load joins the trace of the request that rendered the HTML.

Every app response that the add-on sees also gets Access-Control-Expose-Headers: X-WizeLens-Trace, X-WizeLens-Endpoint (append, do not replace). The names must survive a CORS layer that sets the same header later (for example @fastify/cors or Express cors), so the Node add-on merges them in again when the response headers are written.

When an outgoing HTTP call gets a response with X-WizeLens-Endpoint, the add-on adds that URL to the current trace's remotes list (de-duplicated, and never its own endpoint).

Federation and hub sends run with tracing suppressed (OTel suppressTracing), so no layer above the HTTP client (for example the Next.js fetch wrapper) records them as app traffic.

Browser spans and the server that owns their trace

A browser span sets wizelens.browser.remote to the WizeLens endpoint of the server that holds its trace, when that is not the endpoint the page is set up with: from wl-endpoint (page load) or X-WizeLens-Endpoint (fetch). The page then sends the span (and the logs of that trace) to {remote}/api/client-spans when the remote is on the page's own origin, and to its own endpoint otherwise. A server that gets a browser span whose wizelens.browser.remote is on the request's Origin (same origin, http or https) adds it to the trace's remotes, unless it is one of its own endpoints; a remote on any other origin is ignored, so a page cannot point federation at a URL of its choice.

gRPC uses the same key as metadata: a gRPC server sends x-wizelens-endpoint in its response headers (merged into any headers the handler sends) and in its trailers, and a gRPC client reads either one into remotes.

Endpoints

All api/* endpoints return JSON (camelCase) and send Access-Control-Allow-Origin: *.

Method + path Returns
GET / and static files Dashboard UI (from @wizeworks/lens-ui build). Hash routing: #/, #/requests, #/traces, #/traces/{traceId}[/{view}], #/database, #/rendering, #/errors, #/logs, #/services, #/insights, #/settings. {view} is one of timeline, sql, http, logs, errors, insights.
GET api/info ServiceInfo (see Types)
GET api/traces?limit=50&session={id} TraceSummary[], newest first. With session, only traces that have a span whose wizelens.browser.session equals {id}.
GET api/traces?full=true&limit=1000&since={ms} Trace[] from this service only (no federation), newest first. limit max 5000. With since, only traces whose last activity (startMs + durationMs) is at or after {ms}. The dashboard uses this for its charts and merges the results of every service it knows.
GET api/traces/{traceId}?federate=true Trace (404 if unknown and no remote has it)
POST api/client-spans Body { spans: Span[], logs?: (LogRecord & { traceId })[] }. Browser spans and browser log lines. Stored into their traces. At most 500 spans and 100 logs per call; log messages are cut at 4000 characters. 204.
POST mcp (+ GET for SSE if SDK needs) MCP streamable HTTP. Only when MCP is enabled. 404 otherwise.

Federation (federate=true)

  1. Load the local trace (may be missing).
  2. For each URL in remotes, GET {remote}/api/traces/{traceId}?federate=true with X-WizeLens-Hop: n+1. Timeout 2 s. Ignore failures.
  3. Merge: union spans by spanId, union logs, union remotes. Recompute summary fields. service and rootName come from the span with no parent (or the earliest span).

Types

Times are Unix epoch milliseconds as floating point numbers (sub-ms precision allowed).

type SpanKind = "server" | "client" | "internal" | "producer" | "consumer";
type SpanStatus = "unset" | "ok" | "error";
type SpanCategory = "http.server" | "http.client" | "db" | "messaging" | "browser" | "internal";

interface Span {
  traceId: string;          // 32 lowercase hex
  spanId: string;           // 16 lowercase hex
  parentSpanId: string | null;
  name: string;             // "GET /api/orders", "SELECT customers", "fetch /api/orders"
  kind: SpanKind;
  category: SpanCategory;
  service: string;          // "gateway-api", "orders-api", "browser"
  startMs: number;
  durationMs: number;
  status: SpanStatus;
  statusMessage: string | null;
  attributes: Record<string, string | number | boolean | null>;
  events: SpanEvent[];
}

interface SpanEvent {
  name: string;             // "exception" uses OTel exception.* attributes
  timeMs: number;
  attributes: Record<string, string | number | boolean | null>;
}

interface LogRecord {
  timeMs: number;
  level: "trace" | "debug" | "info" | "warn" | "error" | "critical";
  category: string;
  message: string;
  service: string;
  spanId: string | null;
  attributes: Record<string, string | number | boolean | null>;
}

interface TraceSummary {
  traceId: string;
  rootName: string;
  service: string;
  startMs: number;
  durationMs: number;       // max(end) - min(start) over spans
  status: SpanStatus;       // "error" if any span is error
  spanCount: number;
  dbCount: number;
  httpClientCount: number;
  errorCount: number;
  services: string[];       // distinct, sorted
  rootMethod: string | null;     // http.request.method of the root span
  rootUrl: string | null;        // url.path, else url.full, of the root span
  rootStatusCode: number | null; // http.response.status_code of the root span
}

interface Trace extends TraceSummary {
  spans: Span[];            // sorted by startMs
  logs: LogRecord[];        // sorted by timeMs
  remotes: string[];
}

type HostMode = "in-app" | "hub" | "cloud";
type Capability = "federation" | "environments" | "accounts" | "alerts" | "sharing" | "serverStats";

interface ServiceInfo {
  mode?: HostMode;          // absent = "in-app"
  capabilities?: Capability[]; // absent = ["federation"]
  service: string;
  version: string;
  runtime: string;
  mcp: boolean;
  environment?: string;     // ASPNETCORE_ENVIRONMENT / NODE_ENV, e.g. "Development"
  host?: string;            // machine name
  startedMs?: number;
  storage?: { kind: "sqlite" | "memory"; maxTraces: number; maxAgeMs: number };
  analysis?: { slowQueryMs: number; nPlusOneThreshold: number };
  redactKeys?: string[];
  captureResponseBody?: boolean;
  sampling?: SamplingInfo;  // absent = every trace is kept
}

interface SamplingInfo {
  rate: number;              // 0..1
  keepErrors: boolean;
  keepSlowMs: number | null; // server-side spans only
}

Attribute names (OTel semantic conventions)

Category Attributes
http.server http.request.method, http.route, url.path, url.query, http.response.status_code, server.address, client.address (first X-Forwarded-For value, else the socket), user_agent.original, http.request.header.{name} / http.response.header.{name} (lower-case names, values joined with , , max 50 headers, 1 KB each, redacted), http.request.body.size, http.response.body.size, wizelens.request.id, enduser.id (when known), wizelens.tag.{key} (app tags), wizelens.server.routed_ms (when the runtime can tell), wizelens.response.body + wizelens.response.body.truncated (only with body capture on: text and JSON, first 16 KB)
internal wizelens.layer = handler for a controller action or endpoint handler span
gRPC (on http.server / http.client) rpc.system = grpc, rpc.service (e.g. orders.v1.Orders), rpc.method (e.g. GetOrder), rpc.grpc.status_code (number). Name: {service}/{method}. A non-zero status code on the server is an error.
messaging Kind producer (send) or consumer (receive / process). messaging.system (rabbitmq, kafka, servicebus), messaging.operation.type (send, receive, process), messaging.destination.name (queue, topic, or exchange), messaging.message.id, messaging.batch.message_count, messaging.message.body.size. Name: {operation} {destination}.
http.client http.request.method, url.full, server.address, server.port, http.response.status_code
db A span is db when it has any of db.system.name, db.system, db.query.text, db.operation.name, db.collection.name (some libraries, such as Better Auth, send only the operation and the table). db.system.name (e.g. sqlite, postgresql, microsoft.sql_server), db.query.text, db.operation.name, db.collection.name, db.namespace, db.response.returned_rows, wizelens.db.parameters (JSON string, redacted), wizelens.db.adapter (ado.net, efcore, pg, mysql2, better-sqlite3, node-sqlite, prisma, typeorm, drizzle, ...). An ORM span (Prisma, TypeORM) hides the driver spans under it, so a query is counted once.
exception event exception.type, exception.message, exception.stacktrace
browser (all) wizelens.browser.type (fetch when absent, pageview, react, error), wizelens.browser.session (per-tab id, kept in sessionStorage key wizelens.session), wizelens.browser.route (path shape of the page), url.full
browser fetch http.request.method, http.response.status_code, wizelens.browser.page, wizelens.browser.render_ms (ms from the response to the next paint; absent when not measured)
browser pageview Own trace per page view. Span start = navigation start (or route change), duration = render time. wizelens.browser.navigation (load / spa), wizelens.page.ttfb_ms, .ssr_ms (responseStart − requestStart), .client_render_ms (FCP − responseEnd), .fcp_ms, .lcp_ms, .cls, .inp_ms, .load_ms, .hydration_ms (first React commit), .render_ms, .image_ms (slowest image), .images, .large_images (images bigger than shown and not lazy)
browser react In the page view's trace. wizelens.react.commits, wizelens.react.components = JSON { name, renders, actualMs, selfMs, wasted, context }[] since the last report
browser error In the page view's trace. Status error, one exception event (exception.type, .message, .stacktrace)

Trace context in messages (v0.5)

A producer writes traceparent into the message headers (RabbitMQ headers, Kafka headers, Service Bus application properties). A consumer reads it: the process span joins the producer's trace as a child of the send span, so one trace follows a message across services. Without the header, the consumer starts a new trace.

Library .NET Node
Npgsql, MySqlConnector, Microsoft.Data.SqlClient automatic (their activity sources and diagnostics) —
gRPC automatic (Grpc.AspNetCore, Grpc.Net.Client) instrumentGrpc(wizelens, grpc) for @grpc/grpc-js
RabbitMQ automatic (RabbitMQ.Client 7+) instrumentAmqplib(wizelens, amqplib)
Kafka WizeLens.Kafka package (Confluent.Kafka) instrumentKafkaJs(wizelens, kafka)
Azure Service Bus automatic (Azure.Messaging.ServiceBus) instrumentServiceBus(wizelens, client)
MassTransit automatic —
Fastify / Koa / Hono — wizelensFastify(wizelens), wizelensKoa(wizelens), wizelensHono(wizelens)

Host modes and capabilities (v0.4)

The same dashboard and HUD run in every mode. They read mode and capabilities from api/info and show or hide features by capability, never by the mode name. The badge in the top bar shows the mode, the service, and how long data is kept. Time ranges longer than storage.maxAgeMs are hidden.

Capability Means In-app Hub Cloud
federation The dashboard finds other services through trace remotes and reads them directly ✅ ✅ (services that do not send to the hub)
environments The host holds more than one environment and project; the top-bar picker filters by them ✅ ✅
registry The host knows its services (api/services): online state, project, environment, runtime ✅ ✅
accounts People sign in; the top bar shows the user and the Team page appears ✅
alerts The host runs alert rules; the Alerts page and the bell show them ✅
sharing Traces can be shared by a read-only link ✅
serverStats The host computes page numbers itself (api/models/<page>); ranges reach 7 and 30 days ✅
metrics The host keeps metrics; the Metrics page appears ✅ ✅ ✅
serverMetrics The host computes the Metrics page itself (api/models/metrics). Without it, the browser reads api/metrics/points ✅ ✅

Add-ons answer mode: "in-app" and capabilities: ["federation"]. The hub answers mode: "hub" and capabilities: ["federation", "environments", "registry"]. Add-ons also report project and hub (the hub they send to) when set.

Add-on options (v0.3)

Option .NET Node Default
Capture response bodies CaptureResponseBody captureResponseBody false
Sampling rate (0..1) Sampling.Rate sampling.rate 1, or WIZELENS_SAMPLE_RATE
Keep traces with an error Sampling.KeepErrors sampling.keepErrors true
Keep traces with a slow server-side span (ms) Sampling.KeepSlowMs sampling.keepSlowMs 1000 (null = off)
Tag the current request WizeLensRequest.Tag(key, value?) wizelens.tag(key, value?) —
Set the current user automatic from HttpContext.User wizelens.setUser(id) —

Metrics (v0.8)

WizeLens keeps OpenTelemetry metrics next to traces. A point (MetricPoint, @wizeworks/lens-core):

{ name, kind: "gauge" | "sum" | "histogram", unit, description?, service,
  monotonic?,                          // sums: true for counters
  temporality?: "delta" | "cumulative", // sums and histograms
  attributes, timeMs, startMs?,
  value?,                              // gauges and sums
  histogram?: { count, sum, min?, max?, bounds: number[], counts: number[] } } // counts has bounds.length + 1 entries

A series is one name, one service, and one attribute set. Exponential histograms become explicit buckets (the upper bound of positive bucket i is base^(offset+i+1), base = 2^(2^-scale); zero and negative counts go to the first bucket). A summary becomes a histogram with count and sum and no buckets.

How the page draws them (metricsModelOf, the same code everywhere): gauges and up-down sums as their value; counters as a rate per second (a drop means the counter restarted); histograms as p50, p95, and p99 from the bucket counts (cumulative points are turned into the change since the previous point). Lines can be split by service or by an attribute.

Metric rollups. The model reads per-series rollups, never a capped set of points. Hubs and the cloud write them at ingest, in the same transaction as the raw points, into three tiers: metric_10s (kept 6 h 10 min), metric_5m (kept 50 h), and metric_1h (kept for the retention). A tier row holds, per series and bucket, the point count and the sum, min, and max of the values, the first and last value with their times, a counter's change and the time it took (rate_delta, rate_ms), and a histogram's change (h_count, h_sum, h_min, h_max, and h_bounds / h_counts as JSON). metric_series keeps what each series is (name, service, kind, unit, attributes, from its newest point) and its newest point, so a cumulative counter or histogram gets the change since its previous point across batches and window edges: the first point of a series inside a window counts its change from the point before the window. A point that is not newer than the stored one adds its value but no change. Pages read the tiers the way they read the stats rollups (see "Stats tables"); meta.sampled is always null. In-app hosts send raw points and the browser builds the same rollups in memory (metricsModel({ points, range, now, params })). The runtime strip shows, per service, the first of these that it sends:

Tile Metric names
CPU process.cpu.utilization, dotnet.process.cpu.time, process.cpu.time
Memory process.memory.usage, dotnet.process.memory.working_set
Heap v8js.memory.heap.used, dotnet.gc.last_collection.heap.size, jvm.memory.used
Event loop delay nodejs.eventloop.delay.p99
GC dotnet.gc.pause.time, v8js.gc.duration, jvm.gc.duration
Thread pool queue dotnet.thread_pool.queue.length
Request time http.server.request.duration (p95)

Add-ons collect every 10 seconds and keep one hour in memory (at most 50,000 points and 1,000 series). GET {base}/api/metrics/points?since=<epoch ms> answers { points: MetricPoint[] }, oldest first. Batches to a hub carry the same points in IngestBatch.metrics.

  • Node collects process.cpu.utilization, process.cpu.time, process.memory.usage, v8js.memory.heap.used and .limit, nodejs.eventloop.utilization, nodejs.eventloop.delay.p50 / .p99 / .max, and v8js.gc.duration. App metrics come in through wizelensMetricExporter(wizelens) in an OpenTelemetry PeriodicExportingMetricReader.
  • .NET listens to every Meter with a MeterListener: the .NET runtime (dotnet.*), ASP.NET Core (http.server.request.duration, kestrel.*), HttpClient, and the app's own meters. Counters are sent as cumulative totals, histograms as delta per interval, with the instrument's advised bucket bounds.
Option .NET Node Default
Turn metrics on CaptureMetrics captureMetrics true
How often MetricsInterval metricsIntervalMs 10 s
How long they are kept MetricsMaxAge metricsMaxAgeMs 1 h

MCP: list_metrics (range?, service?, q?) and get_metric (name, range?, service?, group?, stat?), read-only.

Redaction

Before storing, replace values with "[redacted]" when the key (case-insensitive) contains any of: authorization, cookie, password, passwd, secret, token, apikey, api_key, connectionstring. Applies to attributes, DB parameter names, and header names. List is configurable.

Analysis (shared logic, same results in .NET and JS)

  • Duplicate query: ≥ 2 db spans in one trace with identical db.query.text AND identical wizelens.db.parameters.
  • N+1 query: ≥ 5 (configurable) db spans in one trace with identical db.query.text (params may differ).
  • Session statements (SET, RESET, BEGIN, START TRANSACTION, COMMIT, END, ROLLBACK, SAVEPOINT, RELEASE, DISCARD, USE) are never duplicates or N+1. Apps that set SET LOCAL app.tenant_id per query repeat them by design.
  • N+1 HTTP: ≥ 5 http.client spans in one trace with the same method + host + path shape (path shape = path with numeric / guid segments and query values replaced by {}).
  • Slow query: db span with durationMs ≥ 100 (configurable).
  • Database wait (dashboard and HUD): 3 or more slow queries in one trace that overlap in time are one db-wait finding, not 3 slow queries. So is a slow session statement on its own (a SET does almost no work, so it waited). Its time is wall time: queries that waited together waited once. The MCP find_slow_queries tool still lists each query.

Timing

  • Database and outbound times are wall time: spans that overlap count once. "All queries added up" is shown next to it when queries ran at the same time. A request can never show more database time than server time.
  • Span start and end times keep sub-millisecond precision, so queries that start in the same millisecond keep their order.

Sampling

A trace is kept by rate when parseInt(last 8 hex digits of traceId, 16) / 2^32 < rate, so every add-on keeps the same traces. A trace outside the rate is held (spans, logs, remotes) until its local root span ends (no parent, or a parent in another process); it is kept when any span has status error (with keepErrors), an error log line, or a non-browser span at least keepSlowMs long. Otherwise it is dropped. A held trace whose root never ends is dropped after 60 s. Items that come after the choice follow it; an error after a drop keeps the rest of the trace. Sampling applies to the store, the SQLite file, and the hub sends alike.

Not traced

  • CORS preflights (OPTIONS with Access-Control-Request-Method). The browser sends them with no traceparent, so they could never join a trace, and in a cross-origin app they are half of all requests.

MCP tools (read-only, phase 1)

Server name wizelens. Disabled by default. Every call is written to the app log as an audit line.

Tool Args Returns
list_traces limit?=20, minDurationMs?, status? TraceSummary[]
get_trace traceId, federate?=true Trace (spans + logs, redacted)
find_slow_queries thresholdMs?=100, limit?=20 { traceId, spanId, durationMs, query, service }[]
find_n_plus_one minRepeats?=5 { traceId, kind: "db"|"http", shape, count, totalMs }[]
get_errors limit?=20 { traceId, spanId, service, name, message, type, stack }[]

Storage (v0.2)

Every add-on keeps a hot in-memory store (the read path) and, optionally, a SQLite file behind it.

Option .NET Node Default
Storage kind Storage = WizeLensStorage.Sqlite | Memory storage: "sqlite" | "memory" sqlite when enabled
File StoragePath storagePath {contentRoot or cwd}/.wizelens/traces.db
Max traces MaxTraces maxTraces 2000
Max age MaxAge maxAgeMs 24 h

Behavior:

  1. On startup: open the file (create folder + schema), enable WAL, delete rows past the limits, load the newest MaxTraces traces into memory.
  2. While running: changed traces are marked dirty. A background flusher writes dirty traces in one transaction every 500 ms, and once more on shutdown. Spans and logs are upserted / inserted, never rewritten in full.
  3. After each flush: prune by age and count (same rules as the memory ring buffer).
  4. If the file cannot be opened or written, log one warning and fall back to memory only. Never break the app.

Schema (shared: wizelens-hub --import <file> copies any add-on's file into the hub):

CREATE TABLE IF NOT EXISTS meta   (key TEXT PRIMARY KEY, value TEXT NOT NULL);  -- ('schema','1'), ('service', name)
CREATE TABLE IF NOT EXISTS traces (
  trace_id TEXT PRIMARY KEY, start_ms REAL NOT NULL, updated_ms REAL NOT NULL,
  remotes TEXT NOT NULL DEFAULT '[]'                                             -- JSON string[]
);
CREATE TABLE IF NOT EXISTS spans  (
  trace_id TEXT NOT NULL, span_id TEXT NOT NULL, parent_span_id TEXT,
  name TEXT NOT NULL, kind TEXT NOT NULL, category TEXT NOT NULL, service TEXT NOT NULL,
  start_ms REAL NOT NULL, duration_ms REAL NOT NULL, status TEXT NOT NULL,
  session TEXT,                                                                  -- wizelens.browser.session
  json TEXT NOT NULL,                                                            -- full contract Span
  PRIMARY KEY (trace_id, span_id)
);
CREATE TABLE IF NOT EXISTS logs   (
  trace_id TEXT NOT NULL, seq INTEGER NOT NULL, time_ms REAL NOT NULL, level TEXT NOT NULL,
  json TEXT NOT NULL,                                                            -- full contract LogRecord
  PRIMARY KEY (trace_id, seq)
);
CREATE INDEX IF NOT EXISTS ix_traces_start  ON traces(start_ms);
CREATE INDEX IF NOT EXISTS ix_spans_session ON spans(session) WHERE session IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_spans_cat_dur ON spans(category, duration_ms);

Summaries are always computed from spans, never stored. Deleting a trace deletes its spans and logs.

Local hub (v0.6, phase 2)

One process (@wizeworks/lens-hub, command wizelens-hub, default port 5960) that every service sends to. It serves the same dashboard at /_wizelens/, the same read API, OTLP/HTTP, and MCP.

Sending to the hub

Option .NET Node Environment variable
Hub URL HubUrl hub WIZELENS_HUB (for example http://localhost:5960)
Hub token HubToken hubToken WIZELENS_HUB_TOKEN
Project Project project WIZELENS_PROJECT

The add-on keeps working as before (its own store and dashboard). In addition, it queues every new span, log, and remote and sends them every second in one IngestBatch (at most 1000 of each). With nothing to send, it sends an empty batch every 10 seconds as a heartbeat. When the hub is down, it keeps the queue (at most 20000 items, oldest dropped first), backs off up to 30 seconds, and logs one warning. The calls to the hub are never traced.

// POST {hub}/_wizelens/api/ingest   →  204, or 400 without sender.service
interface IngestBatch {
  sender: { service: string; project?: string; version?: string; runtime?: string; environment?: string;
            host?: string; endpoint?: string; startedMs?: number };   // endpoint = the add-on's own /_wizelens URL
  spans?: Span[];                                  // checked and redacted again by the hub
  logs?: { traceId: string; log: LogRecord }[];
  remotes?: { traceId: string; endpoint: string }[];
}

Hub endpoints

Method Path Notes
GET /_wizelens/api/info mode: "hub"
GET /_wizelens/api/traces Like the add-on, plus filters env, project, before. since means changed since (hub clock), so late spans are found
GET /_wizelens/api/traces/{id} With federate=true, also reads services that do not send to the hub
GET /_wizelens/api/services { services: HubService[], environments, projects, traces, spans }
POST /_wizelens/api/ingest IngestBatch
POST /v1/traces, /v1/logs, /v1/metrics OTLP/HTTP, JSON or protobuf, optional gzip. Also under /_wizelens/v1/...
GET /_wizelens/api/models/metrics The Metrics page: range, project, env, service, metric, group, stat, q. Read from the metric rollups (see "Metrics"), with no point cap
GET /_wizelens/api/models/{page} One page model (see "Page models"): range, project, env, and the page's own parameters. Ranges up to the hub's age limit. serverStats
POST /_wizelens/mcp With --mcp. The same five tools, over the newest 2000 traces of every service, plus list_metrics and get_metric
GET /healthz ok

HubService is the sender plus via ("addon" or "otlp"), firstSeenMs, lastSeenMs, and online (heard from in the last 30 seconds). OTLP apps send only when they have data, so the dashboard shows a quiet OTLP app as "Idle", not "Offline".

Trace remotes that point at a service which sends to the hub are hidden, so the dashboard does not read that service twice.

OTLP mapping

  • Resource service.name → service; service.namespace (or wizelens.project) → project; deployment.environment.name (or deployment.environment) → environment; host.name → host.
  • Span kind, status, events, and attributes map one to one. Category uses the same rules as the add-ons.
  • Older attribute names are copied to the current ones when the current one is missing: http.method, http.status_code, http.url, http.target, http.scheme, db.statement, db.system, db.name, db.operation, net.peer.name, net.peer.port.
  • Log records keep their trace id. Records without one are skipped (WizeLens shows logs per trace). Severity 1–4 trace, 5–8 debug, 9–12 info, 13–16 warn, 17–20 error, 21–24 critical.
  • Metrics: gauge, sum, histogram, exponential histogram, and summary, as described in "Metrics". Metric attributes are redacted.

Access

The hub listens on 127.0.0.1 by default. For a team network, start it with --host 0.0.0.0 --token <text>. With a token, every call needs Authorization: Bearer <token> (add-ons: WIZELENS_HUB_TOKEN; OTLP: OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer <token>). In a browser, open /_wizelens/?token=<token> once: the hub keeps it in an HttpOnly cookie and removes it from the address.

Hub storage

One SQLite file (default ~/.wizelens/hub/hub.db), newest 50000 traces or 7 days. Traces are built from rows when read.

CREATE TABLE traces   (trace_id TEXT PRIMARY KEY, start_ms REAL, end_ms REAL, updated_ms REAL, env TEXT, project TEXT);
CREATE TABLE spans    (trace_id TEXT, span_id TEXT, service TEXT, start_ms REAL, duration_ms REAL, session TEXT, json TEXT,
                       PRIMARY KEY (trace_id, span_id));
CREATE TABLE logs     (trace_id TEXT, key TEXT, time_ms REAL, json TEXT, PRIMARY KEY (trace_id, key));  -- key = hash of the log identity
CREATE TABLE remotes  (trace_id TEXT, endpoint TEXT, PRIMARY KEY (trace_id, endpoint));
CREATE TABLE services (project TEXT, service TEXT, environment TEXT, host TEXT, via TEXT, info TEXT, first_seen REAL, last_seen REAL,
                       PRIMARY KEY (project, service, environment, host));                               -- '' for unknown
CREATE TABLE metrics  (time_ms REAL, project TEXT, env TEXT, service TEXT, name TEXT, series_key TEXT, json TEXT); -- series_key = sha1

Metric points older than the trace age limit are dropped, then the oldest past --max-metric-points (WIZELENS_HUB_MAX_METRIC_POINTS, default 2,000,000). That cap is for raw points only: the metric rollup tiers drop buckets past their keep (10 s and 5 min tiers) or the trace age limit (1 h tier and metric_series) once a minute. A file from before metric rollups gets its stored raw points rolled up once, oldest first, for half a second each minute (meta('metric_backfill') holds the progress, then done); points that arrive meanwhile are rolled up as usual.

Schema version 3 adds the stats tables (see "Stats tables"): traces gains settled_ms and contrib, and the fact, rollup, hist, and metric rollup tables use project in place of project_id ('' for unknown), SQLite types, and keys that start with the trace id (facts) or (kind, bucket_ms, project, env, key) (rollups), since hub reads often have no project. Files from older hubs upgrade by themselves when opened: the new columns and tables are added, and the settle job backfills the stats, newest traces first. The hub settles every 2 seconds for at most 150 ms at a time. Pruning by age or by count takes the dropped traces out of the rollups. Page answers are cached per page, parameters, scope, 10-second "now", and settle generation (200 entries).

Cloud (v0.7, phase 3)

@wizeworks/lens-cloud (command wizelens-cloud, default port 5970) is the hosted service. It serves the same dashboard, answers mode: "cloud" with capabilities: ["environments", "registry", "accounts", "alerts", "sharing", "serverStats"], and keeps data in Postgres (DATABASE_URL=postgres://...). PGlite (pglite:<folder> or memory) runs the same SQL in-process, for tests and trials.

Tenancy and access

  • Organizations hold projects. People join organizations with a role: owner, admin, member, viewer. Viewers read. Members also make alerts and share links. Admins also manage projects, keys, and members. Owners also manage owners. An organization always keeps one owner.
  • Sessions: email and password (scrypt), an HttpOnly wizelens_session cookie (30 days, SameSite=Lax, Secure on https). Every change from a browser also needs the header X-WizeLens-Client: dashboard, so another site cannot send it.
  • Sign-up: the first account sets up the cloud (api/auth/setup). After that, people join with an invite link (7 days, one use), or anyone can sign up when the cloud runs with --signup open (they get their own organization).
  • Keys (only their SHA-256 hash is stored; the prefix tells what they are):
    • wl_ik_… ingest key: one project. Add-ons send it as WIZELENS_HUB_TOKEN; OTLP apps as Authorization: Bearer …. The project comes from the key, never from the sender. At most 1200 calls per key per minute (429 above).
    • wl_rk_… read key: one or more projects, read-only. For AI agents (MCP) and scripts.
  • Audit log: org changes, member changes, keys, projects, shares, and every MCP tool call, with who did it.

Endpoints (below /_wizelens/)

Method Path Who
GET api/auth/state Anyone: { signedIn, needsSetup, signup, user?, orgs?[] with projects }
POST api/auth/setup, signup, login, logout Anyone (setup only while no account exists)
GET / POST api/auth/invites/{token}, …/accept Anyone / signed in
POST / GET / PATCH api/orgs, api/orgs/{org} Signed in; read: any member; change: admin
POST / PATCH / DELETE api/orgs/{org}/invites, …/members/{user} Admin
POST / DELETE api/orgs/{org}/projects[/{id}], …/{id}/clear (deletes data only) Admin
POST / DELETE api/orgs/{org}/keys[/{id}] Admin. The secret is in the answer once
GET api/orgs/{org}/audit?before= Admin
GET api/info, api/traces, api/traces/{id}, api/services — all with ?project= Member or read key
GET api/models/{page}?project=&range=&env=&… Member or read key. See "Page models". metrics is a page too
GET / POST / PATCH / DELETE api/projects/{p}/alerts[/{id}], …/{id}/test Read: viewer; change: member
POST / GET / DELETE api/projects/{p}/shares[/{id}] Read: viewer; change: member
GET api/shared/{token} Anyone with the link: { trace, expiresMs }
POST api/ingest, /v1/traces, /v1/logs Ingest key
POST mcp?project= Read key (the project can be left out when the key has one)

Page models (serverStats)

Every dashboard page is one definition in @wizeworks/lens-core (PAGES: overview, requests, traces, database, rendering, errors, logs, services, insights, and badges, the sidebar counts). A page says what it needs from its URL parameters (filters, sort, page, selected item): rollup groups and series over the chosen window and the window before it, list rows, or one trace. An executor answers those needs and the page builds a JSON object from the answers. In-app hosts answer them in the browser from the loaded traces; hosts with serverStats answer them on the server, so ranges reach 7d and 30d. Lists in a model are capped at 200 rows (Requests and Logs page through all rows). Request rows in models carry no findings. meta.sampled is always null; meta.settling is the number of traces whose stats are still being added up (null or 0 when complete), and the dashboard says "Preparing stats" while it is above 0. The cloud caches answers for 15 seconds; the hub until its stats change.

Stats tables (hub and cloud)

Pages read small tables built from the stored traces, never the traces themselves (only selected traces are read whole).

  • Facts, one set per trace: fact_requests (a request row without findings), fact_traces (a Traces list row), fact_logs (log lines), fact_critical (the 5 slowest spans of each trace). Lists filter, sort, and page these.
  • Rollups: what a trace adds to every grouping a page shows (kind, key), summed per time bucket in three tiers: rollup_10s (kept 6 h 10 min), rollup_5m (kept 50 h), rollup_1h (kept for the retention). Each row has n, err, sum_v, counters c0..c5, first_ms, last_ms, and one representative (top, top_rank) and newest trace ids (ids). hist_<tier> holds a log-bucket histogram per row (bins 4% wide, so percentiles are within 2%).
  • A query reads the coarsest tier that still gives a window enough buckets, and finer tiers at the window's edges; an edge bucket that no kept tier can split is weighted by its overlap. Series place each tier bucket by its midpoint, or spread it over the page buckets it overlaps when page buckets are not whole tier buckets.
  • Settling: a trace is settled when it has been quiet for 5 seconds (or every 30 seconds while it keeps changing): its facts are rebuilt from its spans and logs, and the rollups get its new contribution minus the old one (kept as JSON in traces.contrib). settled_ms is set to the updated_ms that was read, so a late span settles the trace again. Representatives, first and last times, and ids are display fields and are never taken back out.
  • The slow query threshold is fixed when a trace settles (100 ms). FACTS_VERSION in @wizeworks/lens-core names the fact rules: when it changes, a host clears its stats and rebuilds them (hub: meta('facts'); cloud: scale_meta('facts')).
  • Times are those of each item (request start, span start, log time), so an item near a window edge counts where it happened.
  • Metric rollups (metric_series, metric_10s / 5m / 1h) use the same tiers and reads, but are written when points arrive, not by settling, since points never change (see "Metrics").

Alerts

A rule watches one metric over a window (5 min to 24 h), optionally in one environment: error_rate (%), p95_ms, avg_ms, requests, new_errors (error groups not seen in the window before), slow_queries. It fires when the value is above (>) or below (<) the threshold. No data never fires, except requests < (traffic stopped). Rules run every minute. A notice is sent only when the state changes (fired, resolved), to a webhook (the JSON notice) or a Slack incoming webhook, and kept as an event. The message says what happened: Requests is 79 (above 5) when fired, (no longer above 5) when resolved, and (alerts when above 5) for a test notice. PATCH changes only the fields it sends. Webhook URLs must be https and may not resolve to private, loopback, or link-local addresses, unless the cloud runs with --allow-private-webhooks (self-hosting on a private network).

A rule can also watch one OpenTelemetry metric: metric: "metric", metricName (required), metricStat (last, avg, max, p95, p99, or rate; default avg), and metricService (optional). The threshold is in the metric's base unit (seconds, bytes, or 0 to 1 for ratios). Counters read as a rate per second; histograms read from their merged buckets. Rules read the metric rollups over the window ending at "now" rounded down to 10 seconds. Gauge p95 and p99 are taken over the bucket means of the finest tier that covers the window (10 s up to 6 hours, 5 min beyond), so they can sit slightly below the percentile of the raw values. No data never fires.

Sharing

A share link (#/shared/<token>, 1 to 30 days) opens one trace, read-only, for anyone with the link. It can be revoked. Views are counted. Nothing else is reachable with the token.

Retention

Each organization keeps traces for retention_days (default 30, 1 to 400). Older traces are deleted every 10 minutes. POST …/projects/{id}/clear deletes a project's data now.

Cloud storage

Postgres, one schema, migrations in wizelens_migrations. Tables: orgs, users, members, sessions, invites, projects, api_keys, traces, spans, logs, services, metric_points, alert_rules, alert_events, shares, audit. Migration 2 added metric_points and the metric columns of alert_rules. Migration 3 added the stats tables (see "Stats tables"): traces.settled_ms and traces.contrib, scale_meta, fact_requests, fact_traces, fact_logs, fact_critical, rollup_10s/5m/1h, hist_10s/5m/1h, metric_series, and metric_10s/5m/1h. The cloud settles every 3 seconds in batches of 500, under a Postgres advisory lock so only one instance works at a time. Rollup buckets age out every minute; the 1 h tier follows the organization's retention, and deleting traces past retention takes them out of the rollups first. Metric rollups are written at ingest under row locks on metric_series (SELECT ... FOR UPDATE, in key order), so instances add up the same series one at a time; their tiers age out with the 10-minute retention job (the 1 h tier and metric_series by the organization's retention). Data tables are keyed by project_id. Times are double precision epoch ms. Secrets (sessions, invites, keys, shares) are stored as SHA-256 hashes only.