more otel
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s

This commit is contained in:
mats committed 2026-09-18 20:51:37 +00:00
1 parent 9620e3bfa6
commit 21f04d696b
88 files changed
+23419 -192

No files matched your search

+240
View File
@@ -0,0 +1,240 @@
// OIDC authorization-code flow with PKCE against the go-auth service.
// Client is public (no secret); tokens live in localStorage.
const ISSUER =
(import.meta.env.VITE_OIDC_ISSUER as string | undefined) ??
"https://users.k6n.net";
const CLIENT_ID =
(import.meta.env.VITE_OIDC_CLIENT_ID as string | undefined) ?? "otel-debug";
const SCOPE = "openid profile email offline_access";
const TOKEN_KEY = "otel.auth.tokens";
export interface Tokens {
accessToken: string;
idToken: string;
refreshToken?: string;
expiresAt: number; // epoch ms
}
export interface UserClaims {
sub: string;
name?: string;
email?: string;
roles?: string[];
}
export class AuthRequiredError extends Error {
constructor() {
super("Authentication required")
this.name = "AuthRequiredError"
}
}
function b64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function readTokens(): Tokens | null {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(TOKEN_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as Tokens;
} catch {
return null;
}
}
// tokens() returns tokens if the access token is currently valid.
export function tokens(): Tokens | null {
const t = readTokens();
return t && t.expiresAt > Date.now() + 10_000 ? t : null;
}
export function hasSession(): boolean {
return readTokens() !== null;
}
let refreshing: Promise<Tokens | null> | null = null;
// ensureTokens() returns valid tokens, refreshing if close to expiration.
export async function ensureTokens(): Promise<Tokens | null> {
const t = readTokens();
if (!t) return null;
if (t.expiresAt > Date.now() + 30_000) return t;
if (!t.refreshToken) return null;
refreshing ??= doRefresh(t).finally(() => {
refreshing = null;
});
return refreshing;
}
async function doRefresh(t: Tokens): Promise<Tokens | null> {
try {
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: t.refreshToken!,
}),
});
if (!res.ok) {
localStorage.removeItem(TOKEN_KEY);
return null;
}
const body = (await res.json()) as TokenResponse;
saveTokens(body, t.refreshToken);
return readTokens();
} catch {
return null;
}
}
interface TokenResponse {
access_token: string;
id_token: string;
refresh_token?: string;
expires_in: number;
}
function saveTokens(body: TokenResponse, fallbackRefresh?: string) {
if (typeof window === "undefined") return;
localStorage.setItem(
TOKEN_KEY,
JSON.stringify({
accessToken: body.access_token,
idToken: body.id_token,
refreshToken: body.refresh_token ?? fallbackRefresh,
expiresAt: Date.now() + body.expires_in * 1000,
} satisfies Tokens),
);
}
export function currentUser(): UserClaims | null {
const t = tokens();
if (!t || !t.idToken) return null;
try {
const payload = t.idToken.split(".")[1];
return JSON.parse(
atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
) as UserClaims;
} catch {
return null;
}
}
export function logout() {
if (typeof window === "undefined") return;
localStorage.removeItem(TOKEN_KEY);
const end = `${ISSUER}/oidc/v1/end_session`;
window.location.assign(
`${end}?post_logout_redirect_uri=${encodeURIComponent(window.location.origin)}`,
);
}
function redirectUri(): string {
return `${window.location.origin}/auth_callback`;
}
export async function login() {
if (typeof window === "undefined") return;
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = b64url(verifierBytes);
const challenge = b64url(
new Uint8Array(
await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
),
),
);
const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem(
"otel.auth.pkce",
JSON.stringify({
verifier,
state,
back: window.location.pathname + window.location.search,
}),
);
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: redirectUri(),
response_type: "code",
scope: SCOPE,
code_challenge: challenge,
code_challenge_method: "S256",
state,
});
window.location.assign(`${ISSUER}/oauth/v2/authorize?${params}`);
}
export async function handleCallback(): Promise<string | null> {
if (typeof window === "undefined") return null;
const url = new URL(window.location.href);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) return null;
const saved = JSON.parse(
sessionStorage.getItem("otel.auth.pkce") ?? "null",
) as { verifier: string; state: string; back: string } | null;
sessionStorage.removeItem("otel.auth.pkce");
if (!saved || state !== saved.state) {
throw new Error("OIDC state mismatch");
}
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
redirect_uri: redirectUri(),
code,
code_verifier: saved.verifier,
}),
});
if (!res.ok) {
throw new Error(`Token exchange failed with status ${res.status}`);
}
saveTokens((await res.json()) as TokenResponse);
const destination = saved.back || "/";
window.history.replaceState(null, "", destination);
return destination;
}
export async function authFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const t = await ensureTokens();
// Protected API callers must never send anonymous requests. Apart from
// avoiding unnecessary 401s, this prevents the RUM error reporter from
// turning an expired session into an issues-refresh loop.
if (!t?.accessToken) throw new AuthRequiredError()
const headers = new Headers(init?.headers);
if (t?.accessToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${t.accessToken}`);
}
return fetch(input, { ...init, headers });
}
export async function authSWRFetcher<T>(key: string): Promise<T> {
const response = await authFetch(key)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<T>
}
+85
View File
@@ -0,0 +1,85 @@
// Shared message-partitioning logic used by every ingestion path: the
// initial history fetch, live WebSocket messages, and on-demand trace
// fetches (GET /api/traces/{traceId}). Keeping this in one place means all
// three stay consistent instead of drifting.
import type { LogEntry, MetricData, OtelMessage, TraceData } from "./types"
import { normalizeLogEntry, normalizeMetricData, normalizeTrace } from "./normalize"
export interface PartitionedMessages {
traces: TraceData[]
metrics: MetricData[]
logs: LogEntry[]
logsByTraceId: Record<string, LogEntry[]>
traceIds: Set<string>
}
export function partitionMessages(messages: OtelMessage[]): PartitionedMessages {
const traces: TraceData[] = []
const metrics: MetricData[] = []
const logs: LogEntry[] = []
const logsByTraceId: Record<string, LogEntry[]> = {}
const traceIds = new Set<string>()
messages.forEach((message) => {
switch (message.type) {
case "traces": {
const normalized = normalizeTrace(message.data)
traces.push(normalized)
normalized.resourceSpans.forEach((rs) =>
rs.scopeSpans.forEach((ss) =>
ss.spans.forEach((span) => {
if (span.traceId) traceIds.add(span.traceId)
}),
),
)
break
}
case "metrics":
metrics.push(normalizeMetricData(message.data))
break
case "logs": {
const logsWithId = (message.data.resourceLogs ?? []).map((log) =>
normalizeLogEntry({
...log,
id: crypto.randomUUID(),
scopeLogs: (log.scopeLogs ?? []).map((sl) => ({
...sl,
logRecords: (sl.logRecords ?? []).map((record) => ({
...record,
uid: record.uid ?? crypto.randomUUID(),
})),
})),
}),
)
logsWithId.forEach((log) => {
logs.push(log)
log.scopeLogs.forEach((scopeLog) => {
scopeLog.logRecords.forEach((record) => {
if (record.traceId) {
if (!logsByTraceId[record.traceId]) {
logsByTraceId[record.traceId] = []
}
logsByTraceId[record.traceId].push(log)
}
})
})
})
break
}
}
})
return { traces, metrics, logs, logsByTraceId, traceIds }
}
export function mergeLogsByTraceId(
prev: Record<string, LogEntry[]>,
additions: Record<string, LogEntry[]>,
): Record<string, LogEntry[]> {
if (Object.keys(additions).length === 0) return prev
const next = { ...prev }
Object.entries(additions).forEach(([traceId, entries]) => {
next[traceId] = [...(next[traceId] ?? []), ...entries]
})
return next
}
+95
View File
@@ -0,0 +1,95 @@
// Helpers for presenting backend issues in the UI: trend sparkline data,
// breadcrumb parsing and triage-state presentation.
import type { Issue, IssueSample } from "./types"
export interface TrendPoint {
hour: string
count: number
}
/**
* Converts an issue's sparse hourly trend map into a dense series covering
* the full look-back window, oldest first - ready for a sparkline.
*/
export function trendPoints(issue: Issue, hours = 24): TrendPoint[] {
const now = new Date()
const currentHour = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours()),
)
const points: TrendPoint[] = []
for (let i = hours - 1; i >= 0; i--) {
const d = new Date(currentHour.getTime() - i * 3_600_000)
const hour = d.toISOString().slice(0, 13) + ":00"
points.push({ hour, count: issue.trend?.[hour] ?? 0 })
}
return points
}
/** True when the issue has at least one occurrence in the last hour. */
export function isSpike(issue: Issue): boolean {
const points = trendPoints(issue, 1)
return (points[0]?.count ?? 0) > 1
}
export interface ParsedBreadcrumb {
t: number
category: string
message: string
}
/** Safely parses the raw breadcrumb JSON attached to a frontend sample. */
export function parseBreadcrumbs(raw?: string): ParsedBreadcrumb[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(b): b is ParsedBreadcrumb =>
b && typeof b === "object" && typeof b.category === "string" && typeof b.message === "string",
)
} catch {
return []
}
}
export type IssueStatus = "open" | "resolved" | "ignored"
export const issueStatusMeta: Record<IssueStatus, { label: string; className: string }> = {
open: { label: "Open", className: "bg-rose-500/10 text-rose-300 border-rose-500/20" },
resolved: {
label: "Resolved",
className: "bg-emerald-500/10 text-emerald-300 border-emerald-500/20",
},
ignored: { label: "Ignored", className: "bg-slate-500/10 text-slate-400 border-white/10" },
}
export function issueStatusOf(issue: Issue): IssueStatus {
if (issue.status === "resolved" || issue.status === "ignored") return issue.status
return "open"
}
/** Most informative stacktrace across an issue's samples, if any. */
export function latestStacktrace(issue: Issue): string | undefined {
for (let i = issue.samples.length - 1; i >= 0; i--) {
if (issue.samples[i]?.resolvedStacktrace) return issue.samples[i].resolvedStacktrace
if (issue.samples[i]?.stacktrace) return issue.samples[i].stacktrace
}
return undefined
}
/** Latest sample carrying breadcrumbs (frontend issues), if any. */
export function latestBreadcrumbs(issue: Issue): ParsedBreadcrumb[] {
for (let i = issue.samples.length - 1; i >= 0; i--) {
const parsed = parseBreadcrumbs(issue.samples[i]?.breadcrumbs)
if (parsed.length) return parsed
}
return []
}
export function sampleContext(sample: IssueSample): string[] {
const parts: string[] = []
if (sample.httpMethod) parts.push(sample.httpMethod)
if (sample.httpRoute) parts.push(sample.httpRoute)
if (sample.httpStatus) parts.push(String(sample.httpStatus))
return parts
}
+74
View File
@@ -0,0 +1,74 @@
// OTLP JSON exporters omit fields when empty, so any of the arrays below can
// be undefined at runtime even though the types say otherwise. Normalize every
// message once at ingestion so consumers can rely on the declared types.
import type { LogEntry, LogRecord, MetricData, TraceData } from "./types"
const attrList = (value: unknown): LogRecord["attributes"] =>
Array.isArray(value) ? (value as LogRecord["attributes"]) : []
const normalizeLogRecord = (record: any): LogRecord => ({
...record,
attributes: attrList(record?.attributes),
})
export const normalizeLogEntry = (log: any): LogEntry => ({
...log,
id: log?.id ?? crypto.randomUUID(),
resource: {
...log?.resource,
attributes: attrList(log?.resource?.attributes),
},
scopeLogs: attrList(log?.scopeLogs).map((scopeLog: any) => ({
...scopeLog,
scope: scopeLog?.scope ?? { name: "unknown" },
logRecords: attrList(scopeLog?.logRecords).map(normalizeLogRecord),
})),
})
export const normalizeTrace = (trace: any): TraceData => ({
...trace,
resourceSpans: attrList(trace?.resourceSpans).map((resourceSpan: any) => ({
...resourceSpan,
resource: {
...resourceSpan?.resource,
attributes: attrList(resourceSpan?.resource?.attributes),
},
scopeSpans: attrList(resourceSpan?.scopeSpans).map((scopeSpan: any) => ({
...scopeSpan,
scope: scopeSpan?.scope ?? { name: "unknown" },
spans: attrList(scopeSpan?.spans).map((span: any) => ({
...span,
attributes: attrList(span?.attributes),
events: attrList(span?.events),
status: span?.status ?? {},
})),
})),
})),
})
export const normalizeMetricData = (data: any): MetricData => ({
...data,
resourceMetrics: attrList(data?.resourceMetrics).map((resourceMetric: any) => ({
...resourceMetric,
resource: {
...resourceMetric?.resource,
attributes: attrList(resourceMetric?.resource?.attributes),
},
scopeMetrics: attrList(resourceMetric?.scopeMetrics).map((scopeMetric: any) => ({
...scopeMetric,
scope: scopeMetric?.scope ?? { name: "unknown" },
metrics: attrList(scopeMetric?.metrics).map((metric: any) => ({
...metric,
gauge: metric?.gauge
? { ...metric.gauge, dataPoints: attrList(metric.gauge.dataPoints) }
: undefined,
sum: metric?.sum
? { ...metric.sum, dataPoints: attrList(metric.sum.dataPoints) }
: undefined,
histogram: metric?.histogram
? { ...metric.histogram, dataPoints: attrList(metric.histogram.dataPoints) }
: undefined,
})),
})),
})),
})
+209
View File
@@ -0,0 +1,209 @@
import type { AttributeValue, OtelAttribute } from "./types";
export function getAttributeValue(
attributes: OtelAttribute[],
key: string,
): string {
const lowerKey = key.toLowerCase();
const attr = attributes.find((a) => a.key === key || a.key.toLowerCase() === lowerKey);
if (!attr) return "";
const { value } = attr;
if (value.stringValue) return value.stringValue;
if (value.intValue) return value.intValue;
if (value.doubleValue !== undefined) return value.doubleValue.toString();
if (value.boolValue !== undefined) return value.boolValue.toString();
return "";
}
export function formatTimestamp(nanoTimestamp: string): string {
const ms = Number(nanoTimestamp) / 1_000_000;
return new Date(ms).toLocaleString();
}
export function formatDuration(startNano: string, endNano: string): string {
const durationMs = (Number(endNano) - Number(startNano)) / 1_000_000;
if (durationMs < 1) {
return `${(durationMs * 1000).toFixed(2)}μs`;
} else if (durationMs < 1000) {
return `${durationMs.toFixed(2)}ms`;
} else {
return `${(durationMs / 1000).toFixed(2)}s`;
}
}
export function getSpanKindLabel(kind: number): string {
const kinds = [
"UNSPECIFIED",
"INTERNAL",
"SERVER",
"CLIENT",
"PRODUCER",
"CONSUMER",
];
return kinds[kind] || "UNKNOWN";
}
export function getServiceName(
resourceAttributes?: OtelAttribute[],
recordAttributes?: OtelAttribute[],
): string {
const keys = ["service.name", "app.kubernetes.io/name", "app", "name"];
if (resourceAttributes) {
for (const k of keys) {
const v = getAttributeValue(resourceAttributes, k);
if (v && v !== "unknown" && v !== "unknown-service") return v;
}
}
if (recordAttributes) {
for (const k of keys) {
const v = getAttributeValue(recordAttributes, k);
if (v && v !== "unknown" && v !== "unknown-service") return v;
}
}
return "unknown-service";
}
export function isSpanError(span: {
status?: { code?: number };
attributes?: OtelAttribute[];
}): boolean {
if (span.status?.code === 2) return true;
const statusStr =
getAttributeValue(span.attributes ?? [], "http.response.status_code") ||
getAttributeValue(span.attributes ?? [], "http.status_code");
if (statusStr) {
const code = parseInt(statusStr, 10);
if (!isNaN(code) && code >= 400) return true;
}
const grpcStr = getAttributeValue(span.attributes ?? [], "rpc.grpc.status_code");
if (grpcStr) {
const code = parseInt(grpcStr, 10);
if (!isNaN(code) && code > 0) return true;
}
return false;
}
export function getPodName(
resourceAttributes?: OtelAttribute[],
pointAttributes?: OtelAttribute[],
): string {
const keys = [
"k8s.pod.name",
"pod.name",
"pod",
"pod_name",
"k8s_pod_name",
"service.instance.id",
"service_instance_id",
"instance.id",
"k8s.pod.uid",
"container.name",
"container_name",
"container.id",
"host.name",
"hostname",
"host",
"instance",
"process.pid",
];
if (resourceAttributes) {
for (const k of keys) {
const v = getAttributeValue(resourceAttributes, k);
if (v) return v;
}
}
if (pointAttributes) {
for (const k of keys) {
const v = getAttributeValue(pointAttributes, k);
if (v) return v;
}
}
return "";
}
export const POD_COLORS = [
{ stroke: "#38bdf8", fill: "#38bdf8", text: "text-sky-400", bg: "bg-sky-500/10", border: "border-sky-500/20" },
{ stroke: "#a855f7", fill: "#a855f7", text: "text-purple-400", bg: "bg-purple-500/10", border: "border-purple-500/20" },
{ stroke: "#34d399", fill: "#34d399", text: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
{ stroke: "#fbbf24", fill: "#fbbf24", text: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
{ stroke: "#fb7185", fill: "#fb7185", text: "text-rose-400", bg: "bg-rose-500/10", border: "border-rose-500/20" },
{ stroke: "#22d3ee", fill: "#22d3ee", text: "text-cyan-400", bg: "bg-cyan-500/10", border: "border-cyan-500/20" },
{ stroke: "#f472b6", fill: "#f472b6", text: "text-pink-400", bg: "bg-pink-500/10", border: "border-pink-500/20" },
{ stroke: "#818cf8", fill: "#818cf8", text: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
];
export function getPodPalette(pod: string) {
return POD_COLORS[hashString(pod) % POD_COLORS.length] ?? POD_COLORS[0];
}
export const formatAttributeValue = (value: AttributeValue): string => {
if (value.stringValue !== undefined) return value.stringValue;
if (value.intValue !== undefined) return value.intValue.toString();
if (value.doubleValue !== undefined) return value.doubleValue.toString();
if (value.boolValue !== undefined) return value.boolValue.toString();
return "unknown";
};
export const servicePalettes = [
{
gradient: "from-violet-600 via-indigo-600 to-sky-600",
ring: "ring-violet-400/40",
text: "text-violet-400",
bg: "bg-violet-400/10",
border: "border-violet-400/20",
},
{
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
ring: "ring-emerald-400/40",
text: "text-emerald-400",
bg: "bg-emerald-400/10",
border: "border-emerald-400/20",
},
{
gradient: "from-amber-600 via-orange-600 to-rose-600",
ring: "ring-amber-400/40",
text: "text-amber-400",
bg: "bg-amber-400/10",
border: "border-amber-400/20",
},
{
gradient: "from-fuchsia-600 via-purple-600 to-indigo-700",
ring: "ring-purple-400/40",
text: "text-fuchsia-400",
bg: "bg-fuchsia-400/10",
border: "border-fuchsia-400/20",
},
{
gradient: "from-sky-500 via-blue-600 to-indigo-700",
ring: "ring-sky-400/40",
text: "text-sky-400",
bg: "bg-sky-400/10",
border: "border-sky-400/20",
},
{
gradient: "from-lime-500 via-emerald-600 to-teal-700",
ring: "ring-lime-400/40",
text: "text-lime-400",
bg: "bg-lime-400/10",
border: "border-lime-400/20",
},
];
export const hashString = (value: string): number => {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
};
export const getServicePalette = (service: string) => {
const palette =
servicePalettes[hashString(service) % servicePalettes.length] ??
servicePalettes[0];
return palette;
};
+31
View File
@@ -0,0 +1,31 @@
// Route ↔ tab mapping. Tabs live in the URL (wouter) so deep links survive
// the OIDC login redirect at the ingress: users.k6n.net sends you back to
// exactly the view you requested, and URLs are shareable/bookmarkable.
// The Go server's SPA fallback serves index.html for all of these paths.
export const TAB_PATHS: Record<string, string> = {
"/issues": "tab0",
"/patterns": "tab8",
"/rollouts": "tab9",
"/traces": "tab1",
"/metrics": "tab2",
"/logs": "tab3",
"/setup": "tab4",
"/trends": "tab5",
"/search": "tab6",
"/frontend": "tab7",
"/rum": "tab7",
}
/** Tab value for a location pathname, or null when the path isn't a tab. */
export function tabForPath(path: string): string | null {
return TAB_PATHS[path] ?? null
}
/** Canonical path for a tab value, or null when the tab has no route. */
export function pathForTab(tab: string): string | null {
for (const [path, value] of Object.entries(TAB_PATHS)) {
if (value === tab) return path
}
return null
}
+25
View File
@@ -0,0 +1,25 @@
export function formatDuration(nano: number): string {
if (nano >= 3600000000000) { // 1 hour in nanoseconds
return `${(nano / 3600000000000).toFixed(1)}h`;
} else if (nano >= 60000000000) { // 1 minute
return `${(nano / 60000000000).toFixed(1)}m`;
} else if (nano >= 1000000000) { // 1 second
return `${(nano / 1000000000).toFixed(1)}s`;
} else if (nano >= 1000000) { // 1 millisecond
return `${(nano / 1000000).toFixed(1)}ms`;
} else if (nano >= 1000) { // 1 microsecond
return `${(nano / 1000).toFixed(1)}μs`;
} else {
return `${nano.toFixed(1)}ns`;
}
}
/** "just now", "2m ago", "3h ago", "5d ago" */
export function formatRelativeTime(msEpoch: number): string {
const seconds = Math.max(0, (Date.now() - msEpoch) / 1000);
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
+268
View File
@@ -0,0 +1,268 @@
// OpenTelemetry data types
export interface OtelAttribute {
key: string;
value: AttributeValue;
}
export interface AttributeValue {
stringValue?: string;
intValue?: string;
doubleValue?: number;
boolValue?: boolean;
}
export interface SpanEvent {
name: string;
timeUnixNano: string;
attributes: OtelAttribute[];
}
export interface Span {
traceId: string;
spanId: string;
parentSpanId?: string;
name: string;
kind: number;
startTimeUnixNano: string;
endTimeUnixNano: string;
attributes: OtelAttribute[];
events: SpanEvent[];
status: Record<string, unknown>;
flags: number;
}
export interface ScopeSpan {
scope: {
name: string;
};
spans: Span[];
}
export interface ResourceSpan {
resource: {
attributes: OtelAttribute[];
};
schemaUrl?: string;
scopeSpans: ScopeSpan[];
}
export interface TraceData {
resourceSpans: ResourceSpan[];
}
export interface MetricDataPoint {
asInt?: string;
asDouble?: number;
attributes: OtelAttribute[];
timeUnixNano?: string;
pod?: string;
// Histogram-specific fields
bucketCounts?: string[];
count?: string;
exemplars?: Array<{
asInt?: string;
asDouble?: number;
spanId: string;
timeUnixNano: string;
traceId: string;
}>;
explicitBounds?: number[];
max?: number;
min?: number;
startTimeUnixNano?: string;
sum?: number;
}
export interface Metric {
name: string;
gauge?: {
dataPoints: MetricDataPoint[];
};
sum?: {
dataPoints: MetricDataPoint[];
};
histogram?: {
dataPoints: MetricDataPoint[];
};
}
export interface ScopeMetric {
scope: {
name: string;
};
metrics: Metric[];
}
export interface ResourceMetric {
resource: {
attributes: OtelAttribute[];
};
scopeMetrics: ScopeMetric[];
}
export interface MetricData {
resourceMetrics: ResourceMetric[];
}
export interface LogRecord {
uid: string;
timeUnixNano: string;
body: {
stringValue: string;
};
attributes: OtelAttribute[];
severityNumber?: number;
severityText?: string;
traceId?: string;
spanId?: string;
}
export interface ScopeLog {
scope: {
name: string;
};
logRecords: LogRecord[];
}
export interface ResourceLog {
resource: {
attributes: OtelAttribute[];
};
scopeLogs: ScopeLog[];
}
export type LogEntry = ResourceLog & {
id: string;
}
export interface LogData {
resourceLogs: ResourceLog[];
}
export interface OtelTraceData {
type: "traces";
data: TraceData;
}
export interface OtelMetricData {
type: "metrics";
data: MetricData;
}
export interface OtelLogData {
type: "logs";
data: LogData;
}
export type OtelHistoryMessage = OtelMessage & {
timestamp: number;
};
export type OtelMessage = OtelTraceData | OtelMetricData | OtelLogData;
// Deduplicated errors derived server-side from traces/logs. See
// GET /api/issues and GET /api/issues/{id} in the go-otel backend.
export interface IssueSample {
timestamp: number;
traceId?: string;
spanId?: string;
message: string;
url?: string;
stacktrace?: string;
resolvedStacktrace?: string;
httpMethod?: string;
httpRoute?: string;
httpStatus?: number;
userAgent?: string;
sourceIp?: string;
release?: string;
environment?: string;
/** Raw JSON breadcrumb trail captured by otel-rum.js (frontend issues). */
breadcrumbs?: string;
}
export interface Issue {
id: string;
title: string;
kind: "span_error" | "log_error" | "frontend_error" | string;
service: string;
count: number;
firstSeen: number;
lastSeen: number;
affectedServices: string[];
samples: IssueSample[];
/** UTC hour-bucket ("2026-09-17T14:00") -> occurrence count, last 48h. */
trend?: Record<string, number>;
status?: "open" | "resolved" | "ignored" | string;
resolvedAt?: number;
normalizedTitle?: string;
patternId?: string;
patternSignature?: string;
}
export interface SimilarIssue {
issue: Issue;
score: number;
reasons: string[];
decision?: "same" | "different" | string;
}
export interface IssuePattern {
id: string;
signature: string;
service: string;
kind: string;
issueCount: number;
occurrenceCount: number;
lastSeen: number;
issueIds: string[];
}
export interface Rollout {
id: string;
namespace: string;
workload: string;
service?: string;
release?: string;
revision?: string;
image?: string;
phase: "progressing" | "completed" | "failed" | string;
reason?: string;
message?: string;
startedAt: number;
completedAt?: number;
lastSeen: number;
generation: number;
desiredReplicas: number;
updatedReplicas: number;
availableReplicas: number;
readyReplicas: number;
}
export interface RolloutImpact {
configured: boolean;
rollout: Rollout;
points: Array<{ timestamp: number; value: string }>;
}
// ---- Aggregate stats (ClickHouse-backed /api/stats/*) -------------------
export interface StatsPoint {
/** ISO-ish bucket label from the server, e.g. "2026-09-17 14:00:00". */
bucket: string;
total: number;
errors: number;
}
export interface ErrorStatsResponse {
granularity: "hour" | "day";
points: StatsPoint[];
}
export interface ReleaseStatsResponse {
series: Record<string, StatsPoint[]>;
}