Files
pleno-vue/src/services/releaseTimeline.js
T
Jeppe B f416e0d462 Merge pull request #72 from copenhagentruckwash/fix-telemetry-that-leaks-passwords
Redact serialized request bodies and omit failed-request bodies when capture is disabled
2026-06-01 21:26:45 +02:00

1009 lines
32 KiB
JavaScript

import { reactive, readonly } from "vue";
import { API_URL } from "@/config.js";
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY;
const MAX_QUEUE_SIZE = 50;
const MAX_FRONTEND_FAILURE_BUFFER_SIZE = 50;
const FRONTEND_FAILURE_EVENT_TYPES = new Set([
"vue_component_error",
"vue_component_warning",
"window_error",
"unhandled_rejection",
]);
const RELEASE_RUNTIME_SOURCES = new Set(["local", "deployment"]);
const RELEASE_RUNTIME_REQUESTED_SOURCES = new Set(["local", "deployment", "auto"]);
const normalizeRuntimeSource = (value) => {
const source = String(value || "").trim().toLowerCase();
return RELEASE_RUNTIME_SOURCES.has(source) ? source : "";
};
const normalizeRuntimeRequestedSource = (value) => {
const source = String(value || "").trim().toLowerCase();
return RELEASE_RUNTIME_REQUESTED_SOURCES.has(source) ? source : "";
};
const createTraceId = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.slice(0, 32);
};
const readTraceId = () => {
if (typeof window === "undefined") {
return createTraceId();
}
try {
const stored = window.localStorage.getItem(TRACE_STORAGE_KEY);
if (stored) {
return stored;
}
const next = createTraceId();
window.localStorage.setItem(TRACE_STORAGE_KEY, next);
return next;
} catch {
return createTraceId();
}
};
const releaseRuntimeStateMutable = reactive({
source: null,
requestedSource: null,
traceId: readTraceId(),
channel: null,
availableChannels: [],
versions: {
frontend: null,
api: null,
service_set: null,
bundle_id: null,
bundle: null,
},
frontendBaseUrl: null,
apiBaseUrl: null,
availability: {
configured: true,
missing: [],
status: "ready",
explicit: false,
},
capturePolicy: {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
},
generatedAt: null,
});
let pendingEvents = [];
let recentFrontendFailureEvents = [];
let flushTimer = null;
let customTransport = null;
export const releaseRuntimeState = readonly(releaseRuntimeStateMutable);
const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
const normalizeReadinessMissingValues = (missing = []) =>
Array.from(
new Set(
(Array.isArray(missing) ? missing : [])
.map((value) => String(value || "").trim())
.filter((value) => value && value !== "release_bundle")
)
);
const normalizeRuntimeBaseUrl = (value, { allowRelative = true } = {}) => {
const raw = String(value || "").trim().replace(/\/+$/, "");
if (!raw || !isTrustedReleaseUrl(raw, { allowRelative })) {
return null;
}
if (raw.startsWith("/") && !raw.startsWith("//")) {
return raw;
}
if (/^https?:\/\//i.test(raw)) {
return raw;
}
return null;
};
const runtimeUrls = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
return {
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url, {
allowRelative: false,
}),
apiBaseUrl: normalizeRuntimeBaseUrl(runtime.api_base_url ?? urls.api_base_url),
};
};
const hasExplicitReleaseRuntime = (runtime) =>
Boolean(
runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false
) || hasOwn(runtime, "versions");
export const configureReleaseRuntime = (runtime = {}) => {
if (!runtime || typeof runtime !== "object") {
return releaseRuntimeStateMutable;
}
const runtimeSource = normalizeRuntimeSource(runtime.source);
if (runtimeSource) {
releaseRuntimeStateMutable.source = runtimeSource;
}
const requestedSource = normalizeRuntimeRequestedSource(runtime.requested_source || runtime.requestedSource);
if (requestedSource) {
releaseRuntimeStateMutable.requestedSource = requestedSource;
} else if (runtimeSource) {
releaseRuntimeStateMutable.requestedSource = runtimeSource;
}
if (runtime.trace_id) {
releaseRuntimeStateMutable.traceId = String(runtime.trace_id);
try {
window.localStorage.setItem(TRACE_STORAGE_KEY, releaseRuntimeStateMutable.traceId);
} catch {
// Storage is optional.
}
}
releaseRuntimeStateMutable.channel = runtime.channel || null;
releaseRuntimeStateMutable.availableChannels = Array.isArray(runtime.available_channels)
? runtime.available_channels
: Array.isArray(runtime.availableChannels)
? runtime.availableChannels
: [];
releaseRuntimeStateMutable.versions = {
frontend: runtime?.versions?.frontend || null,
api: runtime?.versions?.api || null,
service_set: runtime?.versions?.service_set || null,
bundle_id: runtime?.versions?.bundle_id || null,
bundle: runtime?.versions?.bundle || null,
};
const urls = runtimeUrls(runtime);
releaseRuntimeStateMutable.frontendBaseUrl = urls.frontendBaseUrl;
releaseRuntimeStateMutable.apiBaseUrl = urls.apiBaseUrl;
const channel = runtime.channel || null;
const isDefaultChannel =
channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable";
const missingReleaseContent = [];
if (!isDefaultChannel && hasOwn(runtime, "versions")) {
if (!runtime?.versions?.frontend) {
missingReleaseContent.push("frontend_version");
} else if (!urls.frontendBaseUrl) {
missingReleaseContent.push("frontend_base_url");
}
if (!runtime?.versions?.api) {
missingReleaseContent.push("api_version");
} else if (!urls.apiBaseUrl) {
missingReleaseContent.push("api_base_url");
}
}
releaseRuntimeStateMutable.availability = runtime.availability
? (() => {
const missing = normalizeReadinessMissingValues(runtime.availability.missing);
let configured = missing.length === 0;
if (!configured) {
configured = runtime.availability.configured !== false && runtime.availability.status !== "unconfigured";
}
const status =
configured && ["", "unconfigured", "missing_target"].includes(String(runtime.availability.status || ""))
? "ready"
: runtime.availability.status || (configured ? "ready" : "unconfigured");
return {
...runtime.availability,
configured,
missing,
status,
explicit: true,
};
})()
: hasExplicitReleaseRuntime(runtime)
? {
configured: missingReleaseContent.length === 0,
missing: missingReleaseContent,
status: missingReleaseContent.length === 0 ? "ready" : "unconfigured",
explicit: true,
}
: {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
releaseRuntimeStateMutable.capturePolicy = {
enabled: Boolean(runtime?.capture_policy?.enabled),
capture_level: runtime?.capture_policy?.capture_level || "metadata",
all_failure_metadata: runtime?.capture_policy?.all_failure_metadata !== false,
retention_days: Number(runtime?.capture_policy?.retention_days || 14),
};
releaseRuntimeStateMutable.generatedAt = runtime.generated_at || new Date().toISOString();
return releaseRuntimeStateMutable;
};
export const getReleaseRuntimeApiBaseUrl = () => releaseRuntimeStateMutable.apiBaseUrl || API_URL;
export const resolveReleaseApiUrl = (url = "") => {
const value = String(url || "");
if (/^https?:\/\//i.test(value)) {
return rewriteReleaseApiUrl(value);
}
return `${getReleaseRuntimeApiBaseUrl().replace(/\/+$/, "")}/${value.replace(/^\/+/, "")}`;
};
export const rewriteReleaseApiUrl = (url = "") => {
const value = String(url || "");
const runtimeApiUrl = releaseRuntimeStateMutable.apiBaseUrl;
const defaultApiUrl = API_URL.replace(/\/+$/, "");
if (!runtimeApiUrl || runtimeApiUrl === defaultApiUrl || !value) {
return value;
}
if (value === defaultApiUrl) {
return runtimeApiUrl;
}
if (value.startsWith(`${defaultApiUrl}/`)) {
return `${runtimeApiUrl}${value.slice(defaultApiUrl.length)}`;
}
return value;
};
const SENSITIVE_PAYLOAD_KEY_PATTERN =
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn|recaptcha/i;
const CAPTURE_DISABLED_BODY_PLACEHOLDER = "[capture-disabled]";
const truncateReleasePayloadString = (value) =>
value.length > 4000 ? `${value.slice(0, 4000)}\n... [truncated]` : value;
const redactSerializedReleasePayload = (value, depth) => {
const trimmed = value.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return truncateReleasePayloadString(JSON.stringify(redactReleasePayload(JSON.parse(value), depth + 1)));
} catch {
// Fall through to form-encoded and truncation handling.
}
}
if (trimmed.includes("=") && !trimmed.includes("\n")) {
const params = new URLSearchParams(trimmed);
const entries = Array.from(params.entries());
if (entries.length > 0) {
let changed = false;
const redactedParams = new URLSearchParams();
for (const [key, item] of entries.slice(0, 80)) {
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
redactedParams.append(key, "[redacted]");
changed = true;
} else {
redactedParams.append(key, truncateReleasePayloadString(item));
}
}
if (changed) {
return truncateReleasePayloadString(redactedParams.toString());
}
}
}
return truncateReleasePayloadString(value);
};
export const redactReleasePayload = (value, depth = 0) => {
if (depth > 8) {
return "[depth-limit]";
}
if (Array.isArray(value)) {
return value.slice(0, 80).map((item) => redactReleasePayload(item, depth + 1));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.slice(0, 80)
.map(([key, item]) => {
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
return [key, "[redacted]"];
}
return [key, redactReleasePayload(item, depth + 1)];
})
);
}
if (typeof value === "string") {
return redactSerializedReleasePayload(value, depth);
}
return value;
};
const isFailureEvent = (type, severity) => {
const normalizedType = String(type || "").toLowerCase();
const normalizedSeverity = String(severity || "").toLowerCase();
return (
normalizedSeverity === "error" ||
normalizedSeverity === "warning" ||
normalizedType.includes("error") ||
normalizedType.includes("failed") ||
normalizedType.includes("failure")
);
};
const shouldSendEvent = (type, severity) => {
if (isFailureEvent(type, severity)) {
return true;
}
return releaseRuntimeStateMutable.capturePolicy.enabled === true;
};
const omitCapturedBodiesWhenDisabled = (payload) => {
if (!payload || typeof payload !== "object") {
return payload;
}
const nextPayload = { ...payload };
for (const key of ["request", "response"]) {
if (nextPayload[key] && typeof nextPayload[key] === "object" && hasOwn(nextPayload[key], "data")) {
nextPayload[key] = {
...nextPayload[key],
data: CAPTURE_DISABLED_BODY_PLACEHOLDER,
};
}
}
return nextPayload;
};
const sanitizeReleaseTimelinePayload = (type, severity, payload) => {
if (isFailureEvent(type, severity) && !releaseRuntimeStateMutable.capturePolicy.enabled) {
return omitCapturedBodiesWhenDisabled(payload);
}
return payload;
};
export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) => {
const severity = options.severity || payload?.severity || "info";
if (!shouldSendEvent(type, severity)) {
return false;
}
const event = {
type,
severity,
module_key: options.moduleKey || payload?.module_key || null,
route: options.route || payload?.route || (typeof window !== "undefined" ? window.location.pathname : null),
component: options.component || payload?.component || null,
request_id: options.requestId || payload?.request_id || null,
occurred_at: new Date().toISOString(),
payload: redactReleasePayload(sanitizeReleaseTimelinePayload(type, severity, payload)),
};
if (FRONTEND_FAILURE_EVENT_TYPES.has(type)) {
recentFrontendFailureEvents = [event, ...recentFrontendFailureEvents].slice(0, MAX_FRONTEND_FAILURE_BUFFER_SIZE);
}
pendingEvents = [...pendingEvents, event].slice(-MAX_QUEUE_SIZE);
scheduleReleaseTimelineFlush();
return true;
};
export const getRecentFrontendFailureEvents = () =>
recentFrontendFailureEvents.map((event) => ({
...event,
payload: redactReleasePayload(event.payload),
}));
const browserInfoFromUserAgent = (userAgent = "") => {
const ua = String(userAgent || "");
const matchers = [
["Edge", /Edg\/([\d.]+)/],
["Chrome", /Chrome\/([\d.]+)/],
["Firefox", /Firefox\/([\d.]+)/],
["Safari", /Version\/([\d.]+).*Safari/],
];
for (const [name, pattern] of matchers) {
const match = ua.match(pattern);
if (match) {
return { name, version: match[1] || null };
}
}
return { name: "Unknown", version: null };
};
const osInfoFromUserAgent = (userAgent = "") => {
const ua = String(userAgent || "");
const matchers = [
["Windows", /Windows NT ([\d.]+)/],
["Android", /Android ([\d.]+)/],
["iOS", /(?:iPhone|iPad).*OS ([\d_]+)/],
["macOS", /Mac OS X ([\d_]+)/],
["Linux", /Linux/],
];
for (const [name, pattern] of matchers) {
const match = ua.match(pattern);
if (match) {
return { name, version: match[1] ? String(match[1]).replace(/_/g, ".") : null };
}
}
return { name: "Unknown", version: null };
};
const currentDeviceType = () => {
if (typeof window === "undefined") {
return null;
}
const width = Number(window.innerWidth || 0);
if (width > 0 && width < 769) {
return "mobile";
}
if (width >= 769 && width < 1024) {
return "tablet";
}
return "desktop";
};
const versionLabel = (version) => version?.version_label || version?.label || null;
const commitSha = (version) => version?.commit_sha || version?.commit || null;
const RELEASE_SERVICE_DEFINITIONS = Object.freeze([
{ key: "frontend", label: "Frontend", kind: "app" },
{ key: "api", label: "API", kind: "app" },
{ key: "database", label: "Database", kind: "data" },
{ key: "redis", label: "Redis", kind: "data" },
{ key: "minio", label: "MinIO", kind: "data" },
]);
const RELEASE_MISSING_LABELS = Object.freeze({
release_bundle: "Release bundle",
frontend_version: "Frontend version",
frontend_base_url: "Frontend URL",
api_version: "API version",
api_base_url: "API URL",
database_service: "Database service",
redis_service: "Redis service",
minio_service: "MinIO service",
});
const isPlainRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
const firstFilledString = (...values) => {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized) {
return normalized;
}
}
return "";
};
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
return {
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url, {
allowRelative: false,
}),
api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url),
};
};
const releaseRuntimeTraceId = (runtime = {}) => firstFilledString(runtime.traceId, runtime.trace_id);
const releaseRuntimeGeneratedAt = (runtime = {}) => firstFilledString(runtime.generatedAt, runtime.generated_at);
const releaseShortText = (value, length = 12) => {
const normalized = firstFilledString(value);
if (!normalized) {
return "";
}
return normalized.length > length ? normalized.slice(0, length) : normalized;
};
const releaseCommitValue = (version = null) => {
if (!isPlainRecord(version)) {
return "";
}
const commit = version.commit;
if (isPlainRecord(commit)) {
return firstFilledString(commit.sha, commit.commit_sha);
}
return firstFilledString(version.commit_sha, commit);
};
const releaseVersionPrimaryText = (version = null, fallback = "Missing version") => {
if (!isPlainRecord(version)) {
return fallback;
}
return firstFilledString(version.version_label, version.tag, releaseShortText(releaseCommitValue(version)), fallback);
};
const releaseVersionSecondaryText = (version = null) => {
if (!isPlainRecord(version)) {
return "";
}
const parts = [];
const commit = releaseShortText(releaseCommitValue(version));
const repository = firstFilledString(version.repository);
const branch = firstFilledString(version.branch);
if (commit && commit !== firstFilledString(version.version_label, version.tag)) {
parts.push(commit);
}
if (repository || branch) {
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
}
return parts.join(" - ");
};
const releaseStatusTone = (status = "") => {
const normalized = String(status || "").trim().toLowerCase();
if (
[
"failed",
"error",
"critical",
"service_unhealthy",
"unhealthy",
"degraded",
"reconcile_failed",
"restart_failed",
"provision_blocked",
].includes(normalized)
) {
return "danger";
}
if (
[
"missing",
"missing_value",
"not_configured",
"unconfigured",
"deployment_in_progress",
"warning",
"pending",
"queued",
"running",
"deploying",
"building",
"provisioning",
"unknown",
].includes(normalized)
) {
return "warning";
}
return "ok";
};
const releaseServiceStatus = (service = null, fallback = "connected") =>
firstFilledString(
service?.deployment_status,
service?.availability_state,
service?.status,
service?.state,
fallback
);
const releaseServicePrimaryText = (service = null) => {
if (!isPlainRecord(service)) {
return "";
}
const name = firstFilledString(
service.resource_name,
service.label,
service.coolify_service_uuid,
service.health_url,
service.repository
);
const id = Number(service.id || service.target_id || 0);
return [name, id > 0 ? `#${id}` : ""].filter(Boolean).join(" ");
};
const releaseServiceSecondaryText = (service = null) => {
if (!isPlainRecord(service)) {
return "";
}
const parts = [];
const resourceUuid = firstFilledString(service.resource_uuid);
const coolifyServiceUuid = firstFilledString(service.coolify_service_uuid);
const instanceLabel = firstFilledString(service.instance_label, service.coolify_instance_label);
const repository = firstFilledString(service.repository);
const branch = firstFilledString(service.branch);
const replication = isPlainRecord(service.replication) ? service.replication : null;
const replicationStatus = firstFilledString(replication?.last_status?.status, replication?.status);
if (resourceUuid) {
parts.push(`resource ${releaseShortText(resourceUuid)}`);
}
if (coolifyServiceUuid && coolifyServiceUuid !== resourceUuid) {
parts.push(`service ${releaseShortText(coolifyServiceUuid)}`);
}
if (repository || branch) {
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
}
if (instanceLabel) {
parts.push(instanceLabel);
}
if (replicationStatus) {
parts.push(`replication ${replicationStatus}`);
}
return parts.join(" - ");
};
const releaseServiceForKey = (serviceSet = null, key = "") => {
if (!isPlainRecord(serviceSet)) {
return null;
}
const stack = isPlainRecord(serviceSet.stack) ? serviceSet.stack : {};
const dataServices = isPlainRecord(serviceSet.data_services) ? serviceSet.data_services : {};
const targets = isPlainRecord(serviceSet.targets) ? serviceSet.targets : {};
const service = stack[key] || dataServices[key] || targets[key] || null;
return isPlainRecord(service) ? service : null;
};
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable, options = {}) => {
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
const missing = normalizeReadinessMissingValues(availability.missing);
const missingLookup = new Set(missing);
const isDefaultChannel =
channel?.default_channel === true
|| channel?.default_channel === 1
|| String(channel?.slug || "").toLowerCase() === "stable";
const includeInfrastructureDetails = options?.includeInfrastructureDetails === true;
const urls = includeInfrastructureDetails ? releaseRuntimeUrlsForDisplay(runtime) : { frontend: "", api: "" };
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
const bundleId = versions.bundle_id || bundle?.id || null;
const rawServiceSet = isPlainRecord(versions.service_set)
? versions.service_set
: isPlainRecord(bundle?.service_set)
? bundle.service_set
: null;
const serviceSet = includeInfrastructureDetails ? rawServiceSet : null;
const defaultSharedLabel = "Default/shared runtime";
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
const buildAppRow = (key, label, version, url) => {
const missingVersionKey = `${key}_version`;
const missingUrlKey = `${key}_base_url`;
const missingKey = missingLookup.has(missingVersionKey)
? missingVersionKey
: missingLookup.has(missingUrlKey)
? missingUrlKey
: "";
const fallback = isDefaultChannel ? defaultSharedLabel : `Missing ${label} version`;
const status = missingKey
? "missing value"
: isDefaultChannel && !version && !url
? "shared"
: firstFilledString(version?.status, url ? "active" : "unknown");
const primaryText = releaseVersionPrimaryText(version, fallback);
const secondaryText = includeInfrastructureDetails ? releaseVersionSecondaryText(version) : "";
const displayUrl = includeInfrastructureDetails ? url || "" : "";
return {
key,
label,
status,
tone: missingKey ? "warning" : releaseStatusTone(status),
primaryText,
secondaryText,
url: displayUrl,
title: [primaryText, secondaryText, displayUrl]
.filter(Boolean)
.join(" - "),
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
};
};
const buildServiceRow = ({ key, label }) => {
const service = releaseServiceForKey(serviceSet, key);
const missingKey = missingLookup.has(`${key}_service`) ? `${key}_service` : "";
if (service) {
const status = releaseServiceStatus(service);
return {
key,
label,
status,
tone: releaseStatusTone(status),
primaryText: releaseServicePrimaryText(service) || "Connected service",
secondaryText: releaseServiceSecondaryText(service),
title: [releaseServicePrimaryText(service), releaseServiceSecondaryText(service), firstFilledString(service.health_url)]
.filter(Boolean)
.join(" - "),
missingLabel: "",
};
}
const fallbackText = missingKey ? `Missing ${label} service` : defaultSharedLabel;
const status = missingKey ? "missing" : "shared";
return {
key,
label,
status,
tone: missingKey ? "warning" : "ok",
primaryText: fallbackText,
secondaryText: "",
title: fallbackText,
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
};
};
return {
channelLabel: firstFilledString(channel?.name, channel?.slug, isDefaultChannel ? "Stable" : "Unknown channel"),
channelSlug: firstFilledString(channel?.slug),
traceId: releaseRuntimeTraceId(runtime) || "unknown",
generatedAt: releaseRuntimeGeneratedAt(runtime) || "unknown",
availabilityStatus: firstFilledString(availability.status, availability.configured === false ? "unconfigured" : "ready"),
availabilityTone: availability.configured === false || missing.length > 0
? "warning"
: releaseStatusTone(availability.status || "ready"),
bundleLabel: bundleId
? `#${bundleId}${firstFilledString(bundle?.version_label) ? ` ${bundle.version_label}` : ""}`
: defaultSharedLabel,
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
serviceSetLabel: serviceSet
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
: rawServiceSet
? "Restricted to release operators"
: defaultSharedLabel,
missingLabels,
appRows: [
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
buildAppRow("api", "API", apiVersion, urls.api),
],
serviceRows: includeInfrastructureDetails || !rawServiceSet ? RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow) : [],
};
};
export const buildCurrentReleaseHeaders = () => {
const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {};
const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown";
return buildReleaseHeaders({
traceId: releaseRuntimeStateMutable.traceId,
channelSlug: releaseRuntimeStateMutable.channel?.slug || "",
frontendVersion: commitSha(frontendVersion) || versionLabel(frontendVersion) || fallbackFrontendVersion,
});
};
export const buildReleaseTimelineContext = () => {
const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
const browser = browserInfoFromUserAgent(userAgent);
const os = osInfoFromUserAgent(userAgent);
const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {};
const apiVersion = releaseRuntimeStateMutable.versions?.api || {};
const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || "unknown";
return {
trace_id: releaseRuntimeStateMutable.traceId,
source: releaseRuntimeStateMutable.source,
requested_source: releaseRuntimeStateMutable.requestedSource,
channel_slug: releaseRuntimeStateMutable.channel?.slug || null,
route_path: typeof window !== "undefined" ? window.location.pathname : null,
device: {
type: currentDeviceType(),
},
browser,
os,
viewport:
typeof window !== "undefined"
? {
width: window.innerWidth,
height: window.innerHeight,
device_pixel_ratio: window.devicePixelRatio || 1,
}
: null,
frontend: {
version_label: versionLabel(frontendVersion) || fallbackFrontendVersion,
commit_sha: commitSha(frontendVersion) || fallbackFrontendVersion,
},
api: {
version_label: versionLabel(apiVersion),
commit_sha: commitSha(apiVersion),
},
frontend_version: versionLabel(frontendVersion) || fallbackFrontendVersion,
frontend_commit_sha: commitSha(frontendVersion) || fallbackFrontendVersion,
api_version: versionLabel(apiVersion),
api_commit_sha: commitSha(apiVersion),
};
};
const scheduleReleaseTimelineFlush = () => {
if (flushTimer !== null || typeof window === "undefined") {
return;
}
flushTimer = window.setTimeout(() => {
flushTimer = null;
void flushReleaseTimelineEvents();
}, 250);
};
export const flushReleaseTimelineEvents = async () => {
if (pendingEvents.length === 0) {
return { accepted: 0 };
}
const events = pendingEvents;
pendingEvents = [];
const body = {
events,
context: buildReleaseTimelineContext(),
};
try {
if (typeof customTransport === "function") {
return await customTransport(body);
}
const headers = {
"Content-Type": "application/json",
...buildCurrentReleaseHeaders(),
};
const token = typeof window !== "undefined" ? window.localStorage.getItem("token") : null;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(resolveReleaseApiUrl("/release/timeline/events"), {
method: "POST",
headers,
body: JSON.stringify(body),
keepalive: true,
});
return response.ok ? response.json().catch(() => ({ accepted: events.length })) : { accepted: 0 };
} catch {
pendingEvents = [...events, ...pendingEvents].slice(-MAX_QUEUE_SIZE);
return { accepted: 0 };
}
};
export const installReleaseErrorInstrumentation = (app, router) => {
const previousErrorHandler = app?.config?.errorHandler;
const previousWarnHandler = app?.config?.warnHandler;
if (app?.config) {
app.config.errorHandler = (error, instance, info) => {
recordReleaseTimelineEvent(
"vue_component_error",
{
message: error?.message || String(error),
stack: error?.stack || null,
info,
component: instance?.type?.name || instance?.type?.__name || null,
},
{
severity: "error",
moduleKey: "frontend",
component: instance?.type?.name || instance?.type?.__name || null,
}
);
if (typeof previousErrorHandler === "function") {
previousErrorHandler(error, instance, info);
} else {
console.error(error);
}
};
app.config.warnHandler = (message, instance, trace) => {
if (releaseRuntimeStateMutable.capturePolicy.enabled) {
recordReleaseTimelineEvent(
"vue_component_warning",
{
message,
trace,
component: instance?.type?.name || instance?.type?.__name || null,
},
{
severity: "warning",
moduleKey: "frontend",
component: instance?.type?.name || instance?.type?.__name || null,
}
);
}
if (typeof previousWarnHandler === "function") {
previousWarnHandler(message, instance, trace);
}
};
}
if (typeof window !== "undefined") {
window.addEventListener("error", (event) => {
recordReleaseTimelineEvent(
"window_error",
{
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack || null,
},
{ severity: "error", moduleKey: "frontend" }
);
});
window.addEventListener("unhandledrejection", (event) => {
recordReleaseTimelineEvent(
"unhandled_rejection",
{
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack || null,
},
{ severity: "error", moduleKey: "frontend" }
);
});
}
if (router?.afterEach) {
router.afterEach((to, from) => {
if (!releaseRuntimeStateMutable.capturePolicy.enabled) {
return;
}
recordReleaseTimelineEvent(
"route_change",
{
from: from?.fullPath || null,
to: to?.fullPath || null,
},
{ severity: "info", moduleKey: "frontend", route: to?.fullPath || null }
);
});
}
};
export const __resetReleaseTimelineForTests = () => {
pendingEvents = [];
recentFrontendFailureEvents = [];
if (flushTimer !== null && typeof window !== "undefined") {
window.clearTimeout(flushTimer);
}
flushTimer = null;
customTransport = null;
releaseRuntimeStateMutable.source = null;
releaseRuntimeStateMutable.requestedSource = null;
releaseRuntimeStateMutable.traceId = "test-trace";
releaseRuntimeStateMutable.channel = null;
releaseRuntimeStateMutable.availableChannels = [];
releaseRuntimeStateMutable.versions = { frontend: null, api: null, service_set: null, bundle_id: null, bundle: null };
releaseRuntimeStateMutable.frontendBaseUrl = null;
releaseRuntimeStateMutable.apiBaseUrl = null;
releaseRuntimeStateMutable.availability = {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
releaseRuntimeStateMutable.capturePolicy = {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
};
releaseRuntimeStateMutable.generatedAt = null;
};
export const __setReleaseTimelineTransportForTests = (transport) => {
customTransport = transport;
};