You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/06/11 02:41:45 UTC
(skywalking-horizon-ui) 01/01: feat(layer): Browser Logs tab — JS error logs + source-map de-obfuscation
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch feat/browser-error-logs-source-maps
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 20f4f06c40dd541d486399eb089a3f1153f7bd21
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Thu Jun 11 10:41:20 2026 +0800
feat(layer): Browser Logs tab — JS error logs + source-map de-obfuscation
Add a Browser Logs tab to the BROWSER layer that lists queryBrowserErrorLogs
rows and resolves minified JS stacks against source maps. Maps are held in BFF
process memory: uploaded .map files are temporary (evicted least-recently-used
past a configurable budget, lost on restart), while a static mount provides
durable provisioning. Resolution runs server-side via @jridgewell/trace-mapping,
so maps and their sourcesContent never ship to the browser.
Implements apache/skywalking#6784 entirely in Horizon UI — no OAP, agent, or
query-protocol change.
- BFF: POST /api/layer/:key/browser-errors (SECOND step, OAP-server-local time,
per-page range, DESC re-sort for BanyanDB segment order); source-map admin
routes (multipart upload / list / delete / resolve); a process-global
SourceMapStore with budget accounting, LRU eviction, lazy mount reads, and a
bounded parsed-map cache.
- UI: layer/browser-errors view + composable + embedded source-map manager; tab
wiring across sidebar / router / caps / topbar; the tab owns its time range
and pauses auto-refresh.
- Config: new sourceMaps block (defaults 64 MiB/file, 512 MiB total); RBAC verbs
browser-errors:read (list/resolve) and source-map:write (upload/delete).
- i18n: every operator string wrapped; 44 keys added to English and translated
into the 7 other catalogs.
- Docs: operate/browser-source-maps.md (+ menu entry), plus horizon.yaml,
container-image, and RBAC updates. The bundled BROWSER template gains the
browserErrors widget; the generic logs tab is disabled there.
---
.claude/skills/local-boot/horizon.demo.yaml | 5 +
.claude/skills/local-boot/horizon.local.yaml | 4 +
CHANGELOG.md | 19 +
Dockerfile | 8 +-
apps/bff/package.json | 3 +
apps/bff/src/bundled_templates/layers/browser.json | 3 +-
apps/bff/src/config/schema.ts | 43 +
apps/bff/src/http/admin/source-maps.test.ts | 189 ++++
apps/bff/src/http/admin/source-maps.ts | 167 ++++
apps/bff/src/http/query/browser-errors.ts | 246 +++++
apps/bff/src/http/query/menu.ts | 3 +-
apps/bff/src/logic/browser-errors/resolve.test.ts | 136 +++
apps/bff/src/logic/browser-errors/resolve.ts | 122 +++
apps/bff/src/logic/browser-errors/store.test.ts | 181 ++++
apps/bff/src/logic/browser-errors/store.ts | 382 ++++++++
apps/bff/src/logic/layers/loader.ts | 2 +
apps/bff/src/rbac/route-policy.ts | 9 +
apps/bff/src/rbac/verbs.ts | 3 +
apps/bff/src/server.ts | 21 +
apps/ui/src/api/client.ts | 61 ++
apps/ui/src/api/scopes/browser-errors.ts | 67 ++
.../admin/layer-templates/LayerDashboardsAdmin.vue | 6 +-
apps/ui/src/i18n/locales/de.json | 46 +-
apps/ui/src/i18n/locales/en.json | 46 +-
apps/ui/src/i18n/locales/es.json | 46 +-
apps/ui/src/i18n/locales/fr.json | 46 +-
apps/ui/src/i18n/locales/ja.json | 46 +-
apps/ui/src/i18n/locales/ko.json | 46 +-
apps/ui/src/i18n/locales/pt.json | 46 +-
apps/ui/src/i18n/locales/zh-CN.json | 46 +-
apps/ui/src/layer/LayerShell.vue | 1 +
.../browser-errors/LayerBrowserErrorsView.vue | 1005 ++++++++++++++++++++
.../src/layer/browser-errors/SourceMapManager.vue | 268 ++++++
.../layer/browser-errors/useLayerBrowserErrors.ts | 88 ++
apps/ui/src/shell/AppSidebar.vue | 18 +-
apps/ui/src/shell/AppTopbar.vue | 3 +
apps/ui/src/shell/layerFromTemplate.ts | 1 +
apps/ui/src/shell/router/index.ts | 2 +
apps/ui/src/shell/useLayers.ts | 1 +
docs/access-control/rbac.md | 5 +-
docs/menu.yml | 2 +
docs/operate/browser-source-maps.md | 140 +++
docs/setup/container-image.md | 2 +
docs/setup/horizon-yaml.md | 7 +-
horizon.example.yaml | 22 +
packages/api-client/src/browser-errors.ts | 168 ++++
packages/api-client/src/index.ts | 14 +
packages/api-client/src/menu.ts | 3 +
pnpm-lock.yaml | 44 +
49 files changed, 3826 insertions(+), 16 deletions(-)
diff --git a/.claude/skills/local-boot/horizon.demo.yaml b/.claude/skills/local-boot/horizon.demo.yaml
index 2185a52..f68f222 100644
--- a/.claude/skills/local-boot/horizon.demo.yaml
+++ b/.claude/skills/local-boot/horizon.demo.yaml
@@ -51,6 +51,7 @@ rbac:
- "alarms:read"
- "traces:read"
- "logs:read"
+ - "browser-errors:read"
- "topology:read"
- "profile:read"
- "overview:read"
@@ -59,6 +60,7 @@ rbac:
- "alarms:read"
- "traces:read"
- "logs:read"
+ - "browser-errors:read"
- "topology:read"
- "profile:read"
- "overview:read"
@@ -71,6 +73,8 @@ rbac:
- "alarms:read"
- "traces:read"
- "logs:read"
+ - "browser-errors:read"
+ - "source-map:write"
- "topology:read"
- "profile:read"
- "cluster:read"
@@ -112,3 +116,4 @@ audit: { file: ./horizon-audit.jsonl }
setup: { file: ./horizon-setup.json }
alarms: { file: ./horizon-alarms.json }
debugLog: { enabled: false, file: ./horizon-wire.jsonl, maxBodyChars: 8192, redactAuthHeaders: true }
+sourceMaps: { enabled: true, maxFileBytes: 67108864, maxTotalBytes: 536870912, maxFileCount: 128, bootMountDir: "" }
diff --git a/.claude/skills/local-boot/horizon.local.yaml b/.claude/skills/local-boot/horizon.local.yaml
index 448d1b6..c21c19b 100644
--- a/.claude/skills/local-boot/horizon.local.yaml
+++ b/.claude/skills/local-boot/horizon.local.yaml
@@ -55,6 +55,7 @@ rbac:
- "alarms:read"
- "traces:read"
- "logs:read"
+ - "browser-errors:read"
- "topology:read"
- "profile:read"
- "overview:read"
@@ -63,6 +64,7 @@ rbac:
- "alarms:read"
- "traces:read"
- "logs:read"
+ - "browser-errors:read"
- "topology:read"
- "profile:read"
- "overview:read"
@@ -75,6 +77,8 @@ rbac:
- "alarms:read"
- "traces:read"
- "logs:read"
+ - "browser-errors:read"
+ - "source-map:write"
- "topology:read"
- "profile:read"
- "cluster:read"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae6451c..136d5af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,25 @@ packages) plus the BFF's `HORIZON_VERSION` default.
## 0.7.0
+### Browser errors & source maps
+
+- **New "Browser Logs" tab on the BROWSER layer** — lists the JS error logs
+ the browser agent reports (message, category, page, app version, time, and
+ the minified `line:col`), filterable by category and time window. Expanding a
+ row shows the raw stack alongside a de-obfuscated view.
+- **Source-map de-obfuscation (issue [#6784](https://github.com/apache/skywalking/issues/6784)).**
+ Upload a `.map` file from the tab and resolve any error's minified stack back
+ to the original source — file, line, column, symbol name, and a source
+ snippet — by picking which map to apply. Maps are held in the BFF's **memory
+ only** (no backend storage): they're surfaced as *temporary*, evicted
+ least-recently-used when the configured budget is hit, and lost on restart. For durable
+ provisioning, mount `.map` files into the server's static source-map directory
+ (`HORIZON_SOURCEMAPS_DIR`, `/app/sourcemaps` in the image) — those reload
+ automatically and can't be deleted from the UI. Budgets are configurable via
+ the new `sourceMaps` block in `horizon.yaml` (per-file and total in-memory
+ caps; defaults 64 MiB / 512 MiB). Upload/delete require the new
+ `source-map:write` permission; viewing + resolving ride on `browser-errors:read`.
+
### Layers
- **Every layer OAP reports now appears in the sidebar**, including ones
diff --git a/Dockerfile b/Dockerfile
index b54ffb2..d77e34b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -65,6 +65,11 @@ COPY --from=build --chown=horizon:horizon /src/dist/bundled_templates ./bundled
RUN mkdir -p /data && chown horizon:horizon /data
VOLUME ["/data"]
+# Static source-map mount point for the Browser Errors tab (#6784). Drop
+# (or bind-mount) `.map` files here and they're indexed at boot — durable
+# across restarts, unlike runtime uploads which live in memory only.
+RUN mkdir -p /app/sourcemaps && chown horizon:horizon /app/sourcemaps
+
ENV NODE_ENV=production \
HORIZON_SERVER_HOST=0.0.0.0 \
HORIZON_SERVER_PORT=8081 \
@@ -73,7 +78,8 @@ ENV NODE_ENV=production \
HORIZON_AUDIT_FILE=/data/horizon-audit.jsonl \
HORIZON_SETUP_FILE=/data/horizon-setup.json \
HORIZON_ALARMS_FILE=/data/horizon-alarms.json \
- HORIZON_WIRE_LOG_FILE=/data/horizon-wire.jsonl
+ HORIZON_WIRE_LOG_FILE=/data/horizon-wire.jsonl \
+ HORIZON_SOURCEMAPS_DIR=/app/sourcemaps
USER horizon
EXPOSE 8081
diff --git a/apps/bff/package.json b/apps/bff/package.json
index f7136da..abfeb5a 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -21,7 +21,9 @@
},
"dependencies": {
"@fastify/cookie": "^11.0.1",
+ "@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.1",
+ "@jridgewell/trace-mapping": "^0.3.31",
"@skywalking-horizon-ui/api-client": "workspace:*",
"argon2": "^0.41.1",
"chokidar": "^4.0.1",
@@ -29,6 +31,7 @@
"ldapts": "^8.1.7",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
+ "stacktrace-parser": "^0.1.11",
"yaml": "^2.6.0",
"zod": "^3.23.8"
},
diff --git a/apps/bff/src/bundled_templates/layers/browser.json b/apps/bff/src/bundled_templates/layers/browser.json
index f10dd31..b5246e1 100644
--- a/apps/bff/src/bundled_templates/layers/browser.json
+++ b/apps/bff/src/bundled_templates/layers/browser.json
@@ -14,7 +14,8 @@
"endpoints": true,
"topology": false,
"traces": true,
- "logs": true
+ "logs": false,
+ "browserErrors": true
},
"layer-header": {
"orderBy": "pv",
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index ed17d7c..51f6fd6 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -174,6 +174,7 @@ const rbacSchema = z
'alarms:read',
'traces:read',
'logs:read',
+ 'browser-errors:read',
'topology:read',
'profile:read',
'overview:read',
@@ -187,6 +188,7 @@ const rbacSchema = z
'alarms:read',
'traces:read',
'logs:read',
+ 'browser-errors:read',
'topology:read',
'profile:read',
'overview:read',
@@ -204,6 +206,8 @@ const rbacSchema = z
'alarms:read',
'traces:read',
'logs:read',
+ 'browser-errors:read',
+ 'source-map:write',
'topology:read',
'profile:read',
'cluster:read',
@@ -319,6 +323,43 @@ const querySchema = z
.strict()
.default({ landingServiceCap: 100 });
+// JS source maps for de-obfuscating BROWSER-layer error stacks (#6784).
+// Maps live in the BFF process heap — there is NO OAP-side storage — so
+// the budgets below bound a per-instance, intentionally-ephemeral cache.
+// `HORIZON_SOURCEMAPS_DIR` is set by the Docker image to /app/sourcemaps
+// so a mounted maps directory is picked up with zero YAML.
+const sourceMapsDirDefault = process.env.HORIZON_SOURCEMAPS_DIR ?? '';
+const sourceMapsSchema = z
+ .object({
+ /** Master switch for the upload / static-mount / resolve capability.
+ * When false the Browser Errors tab still lists errors, but the map
+ * controls are hidden and the source-map routes reject. */
+ enabled: z.boolean().default(true),
+ /** Reject any single `.map` larger than this (upload or mount). Maps
+ * carrying `sourcesContent` are commonly 5–40 MiB; large bundles run
+ * bigger. Default 64 MiB. */
+ maxFileBytes: z.number().int().positive().default(64 * 1024 * 1024),
+ /** Budget for the resident UPLOADED maps (raw `.map` bytes in the Node
+ * heap). An upload bigger than this is rejected; past it, least-recently-
+ * used uploads are evicted. A small parsed-map cache rides on top
+ * (bounded by count + this budget), so plan ~2x headroom; mounted maps
+ * are disk-backed and don't count. Lowering it trims on the next
+ * upload / resolve / list. Default 512 MiB. */
+ maxTotalBytes: z.number().int().positive().default(512 * 1024 * 1024),
+ /** Cap on the NUMBER of maps held, independent of bytes — bounds the
+ * in-memory uploaded set (LRU-evicted past it) and the count of
+ * statically-mounted maps indexed at boot (the rest are skipped).
+ * Default 128. */
+ maxFileCount: z.number().int().positive().default(128),
+ /** Directory scanned at boot for statically-provisioned `.map` files
+ * (Docker/k8s mount). Disk-backed: evictable from memory but reloaded
+ * on demand, so they survive restarts and aren't deletable from the
+ * UI. Empty disables the static mount. */
+ bootMountDir: z.string().default(sourceMapsDirDefault),
+ })
+ .strict()
+ .default({});
+
// Layers hidden from the sidebar / menu even when OAP reports them in
// `listLayers`. Config-driven (replaces a former hard-coded hide list): an
// operator can clear `excluded` to surface every reported layer, or add keys
@@ -357,6 +398,7 @@ export const configSchema = z
alarms: alarmsSchema,
debugLog: debugLogSchema,
query: querySchema,
+ sourceMaps: sourceMapsSchema,
// Deprecated + ignored. The 3D-map config moved to OAP (a template kind);
// the old file-backed `infra3d.file` knob is gone. Accepted here (rather
// than rejected by `.strict()`) so an existing config carrying the block
@@ -366,6 +408,7 @@ export const configSchema = z
.strict();
export type HorizonConfig = z.infer<typeof configSchema>;
+export type SourceMapsConfig = z.infer<typeof sourceMapsSchema>;
export type LdapConfig = z.infer<typeof ldapSchema>;
export type LocalUser = z.infer<typeof localUserSchema>;
export type BreakGlassConfig = z.infer<typeof breakGlassSchema>;
diff --git a/apps/bff/src/http/admin/source-maps.test.ts b/apps/bff/src/http/admin/source-maps.test.ts
new file mode 100644
index 0000000..b431831
--- /dev/null
+++ b/apps/bff/src/http/admin/source-maps.test.ts
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * HTTP-surface acceptance for the source-map routes — the literal "the BFF
+ * api accepts a js + .map analysis" check: upload a real esbuild-emitted
+ * map over multipart, then resolve a stack against it and get original
+ * positions back. Also pins the RBAC gate (read vs write verb).
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+import cookie from '@fastify/cookie';
+import fastifyMultipart from '@fastify/multipart';
+import { transform } from 'esbuild';
+import { configSchema } from '../../config/schema.js';
+import type { ConfigSource } from '../../config/loader.js';
+import { SessionStore } from '../../user/sessions.js';
+import { makeRouteAuthHook } from '../../rbac/route-policy.js';
+import { SourceMapStore } from '../../logic/browser-errors/store.js';
+import { registerSourceMapRoutes } from './source-maps.js';
+
+const SOURCE = [
+ 'function greet(name) {',
+ ' const greeting = "HELLO_MARKER";',
+ ' return greeting + name;',
+ '}',
+ 'globalThis.__greet = greet;',
+].join('\n');
+
+let MAP_JSON: string;
+let MARKER_COL0: number;
+
+beforeAll(async () => {
+ const out = await transform(SOURCE, {
+ loader: 'js',
+ sourcemap: true,
+ minify: true,
+ sourcesContent: true,
+ sourcefile: 'greet.js',
+ });
+ MAP_JSON = out.map;
+ MARKER_COL0 = out.code.indexOf('HELLO_MARKER');
+});
+
+function fakeConfig(): ConfigSource {
+ const cfg = configSchema.parse({});
+ return { current: cfg, current_: () => cfg, path: '', onChange: () => () => {}, close: async () => {} };
+}
+
+async function buildApp(
+ store: SourceMapStore,
+ fileSizeLimit = 64 * 1024 * 1024,
+): Promise<{ app: FastifyInstance; sessions: SessionStore }> {
+ const config = fakeConfig();
+ const sessions = new SessionStore({ ttlMinutes: 60 });
+ const app = Fastify();
+ await app.register(cookie);
+ await app.register(fastifyMultipart, { limits: { fileSize: fileSizeLimit, files: 1 } });
+ app.addHook('onRoute', makeRouteAuthHook({ config, sessions }));
+ registerSourceMapRoutes(app, { config, sessions, store });
+ await app.ready();
+ return { app, sessions };
+}
+
+function multipartBody(filename: string, content: string): { payload: string; contentType: string } {
+ const boundary = '----horizontest';
+ const payload = [
+ `--${boundary}`,
+ `Content-Disposition: form-data; name="file"; filename="${filename}"`,
+ 'Content-Type: application/json',
+ '',
+ content,
+ `--${boundary}--`,
+ '',
+ ].join('\r\n');
+ return { payload, contentType: `multipart/form-data; boundary=${boundary}` };
+}
+
+describe('source-map routes — RBAC + upload', () => {
+ it('401 without a session', async () => {
+ const { app } = await buildApp(new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 })));
+ const { payload, contentType } = multipartBody('app.js.map', MAP_JSON);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/browser-errors/source-maps',
+ headers: { 'content-type': contentType },
+ payload,
+ });
+ expect(res.statusCode).toBe(401);
+ await app.close();
+ });
+
+ it('403 for a reader without source-map:write (viewer)', async () => {
+ const { app, sessions } = await buildApp(new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 })));
+ const sid = sessions.create('v', ['viewer']).sid;
+ const { payload, contentType } = multipartBody('app.js.map', MAP_JSON);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/browser-errors/source-maps',
+ headers: { 'content-type': contentType, cookie: `horizon_sid=${sid}` },
+ payload,
+ });
+ expect(res.statusCode).toBe(403);
+ // ...but a viewer can still list (read verb).
+ const list = await app.inject({ method: 'GET', url: '/api/browser-errors/source-maps', headers: { cookie: `horizon_sid=${sid}` } });
+ expect(list.statusCode).toBe(200);
+ await app.close();
+ });
+
+ it('413 when the upload exceeds the multipart fileSize limit', async () => {
+ const { app, sessions } = await buildApp(new SourceMapStore(() => ({ enabled: true, maxFileBytes: 16, maxTotalBytes: 1 << 30, maxFileCount: 100 })), 16);
+ const sid = sessions.create('op', ['operator']).sid;
+ const { payload, contentType } = multipartBody('app.js.map', MAP_JSON);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/browser-errors/source-maps',
+ headers: { 'content-type': contentType, cookie: `horizon_sid=${sid}` },
+ payload,
+ });
+ expect(res.statusCode).toBe(413);
+ await app.close();
+ });
+});
+
+describe('source-map routes — upload then resolve (js + .map)', () => {
+ it('operator uploads a real map and resolves a stack to original source', async () => {
+ const store = new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 }));
+ const { app, sessions } = await buildApp(store);
+ const sid = sessions.create('op', ['operator']).sid;
+
+ const { payload, contentType } = multipartBody('app.js.map', MAP_JSON);
+ const up = await app.inject({
+ method: 'POST',
+ url: '/api/browser-errors/source-maps',
+ headers: { 'content-type': contentType, cookie: `horizon_sid=${sid}` },
+ payload,
+ });
+ expect(up.statusCode).toBe(200);
+ const upBody = up.json() as { ok: boolean; map: { id: string; label: string; origin: string } };
+ expect(upBody.ok).toBe(true);
+ expect(upBody.map.origin).toBe('upload');
+ const mapId = upBody.map.id;
+
+ const stack = `Error: boom\n at greet (app.js:1:${MARKER_COL0 + 1})`;
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/browser-errors/resolve',
+ headers: { 'content-type': 'application/json', cookie: `horizon_sid=${sid}` },
+ payload: JSON.stringify({ stack, sourceMapId: mapId }),
+ });
+ expect(res.statusCode).toBe(200);
+ const body = res.json() as { ok: boolean; sourceMapLabel: string; frames: Array<{ original: { source: string; line: number } | null }> };
+ expect(body.ok).toBe(true);
+ expect(body.sourceMapLabel).toBe('app.js.map');
+ expect(body.frames[0]?.original?.line).toBe(2);
+ expect(body.frames[0]?.original?.source).toBe('greet.js');
+ await app.close();
+ });
+
+ it('resolve against an unknown map id returns a typed error, not a throw', async () => {
+ const store = new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 }));
+ const { app, sessions } = await buildApp(store);
+ const sid = sessions.create('op', ['operator']).sid;
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/browser-errors/resolve',
+ headers: { 'content-type': 'application/json', cookie: `horizon_sid=${sid}` },
+ payload: JSON.stringify({ stack: 'Error\n at x (a.js:1:1)', sourceMapId: 'nope' }),
+ });
+ expect(res.statusCode).toBe(200);
+ expect((res.json() as { error: string }).error).toBe('unknown_map');
+ await app.close();
+ });
+});
diff --git a/apps/bff/src/http/admin/source-maps.ts b/apps/bff/src/http/admin/source-maps.ts
new file mode 100644
index 0000000..8f84e7d
--- /dev/null
+++ b/apps/bff/src/http/admin/source-maps.ts
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Source-map management for the Browser Errors tab (#6784):
+ *
+ * POST /api/browser-errors/source-maps multipart upload (source-map:write)
+ * GET /api/browser-errors/source-maps list + usage (browser-errors:read)
+ * DELETE /api/browser-errors/source-maps/:id drop an upload (source-map:write)
+ * POST /api/browser-errors/resolve de-obfuscate (browser-errors:read)
+ *
+ * Maps live in the in-memory `SourceMapStore`. Resolution is intentionally
+ * a query-time, operator-driven action: the caller picks the map id, so a
+ * build/version mismatch is the operator's call, not a silent guess.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+// Importing the type also pulls in @fastify/multipart's FastifyRequest
+// augmentation (`req.file()`), so the plugin's API is typed here.
+import type { MultipartFile } from '@fastify/multipart';
+import type {
+ ResolveRequest,
+ ResolveResponse,
+ SourceMapListResponse,
+ SourceMapUploadResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import type { SourceMapStore } from '../../logic/browser-errors/store.js';
+import { resolveStack } from '../../logic/browser-errors/resolve.js';
+
+export interface SourceMapRouteDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+ store: SourceMapStore;
+}
+
+export function registerSourceMapRoutes(app: FastifyInstance, deps: SourceMapRouteDeps): void {
+ const auth = requireAuth(deps);
+ const { store } = deps;
+
+ app.post(
+ '/api/browser-errors/source-maps',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ if (!store.enabled) {
+ return reply.code(403).send({ ok: false, error: 'disabled' } satisfies SourceMapUploadResponse);
+ }
+ let file: MultipartFile | undefined;
+ try {
+ file = await req.file();
+ } catch {
+ // The multipart fileSize limit trips here for oversized streams.
+ return reply.code(413).send({ ok: false, error: 'too_large' } satisfies SourceMapUploadResponse);
+ }
+ if (!file) {
+ return reply.code(400).send({ ok: false, error: 'no_file' } satisfies SourceMapUploadResponse);
+ }
+ let buf: Buffer;
+ try {
+ buf = await file.toBuffer();
+ } catch {
+ return reply.code(413).send({ ok: false, error: 'too_large' } satisfies SourceMapUploadResponse);
+ }
+ if (file.file.truncated) {
+ return reply.code(413).send({ ok: false, error: 'too_large' } satisfies SourceMapUploadResponse);
+ }
+ const result = store.addUpload(file.filename, buf);
+ if (!result.ok) {
+ const code = result.error === 'too_large' ? 413 : result.error === 'disabled' ? 403 : 400;
+ return reply.code(code).send({ ok: false, error: result.error } satisfies SourceMapUploadResponse);
+ }
+ return reply.send({ ok: true, map: result.map } satisfies SourceMapUploadResponse);
+ },
+ );
+
+ app.get(
+ '/api/browser-errors/source-maps',
+ { preHandler: auth },
+ async (_req: FastifyRequest, reply: FastifyReply) => {
+ return reply.send({
+ enabled: store.enabled,
+ maps: store.enabled ? store.list() : [],
+ usage: store.usage(),
+ } satisfies SourceMapListResponse);
+ },
+ );
+
+ app.delete(
+ '/api/browser-errors/source-maps/:id',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const { id } = req.params as { id: string };
+ const removed = store.remove(id);
+ if (!removed) {
+ // Either unknown, or a durable mount (delete the file on disk to
+ // remove those — they reload at boot).
+ return reply.code(404).send({ ok: false, error: 'not_removable' });
+ }
+ return reply.send({ ok: true });
+ },
+ );
+
+ app.post(
+ '/api/browser-errors/resolve',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const body = (req.body ?? {}) as ResolveRequest;
+ const sourceMapId = body.sourceMapId ?? '';
+ if (!store.enabled) {
+ return reply.send({
+ ok: false,
+ sourceMapId,
+ sourceMapLabel: null,
+ frames: [],
+ error: 'disabled',
+ } satisfies ResolveResponse);
+ }
+ if (!body.stack && (body.line === undefined || body.line === null)) {
+ return reply.send({
+ ok: false,
+ sourceMapId,
+ sourceMapLabel: null,
+ frames: [],
+ error: 'no_input',
+ } satisfies ResolveResponse);
+ }
+ const map = await store.getTraceMap(sourceMapId);
+ if (!map) {
+ return reply.send({
+ ok: false,
+ sourceMapId,
+ sourceMapLabel: store.labelOf(sourceMapId),
+ frames: [],
+ error: 'unknown_map',
+ } satisfies ResolveResponse);
+ }
+ const frames = resolveStack(map, {
+ stack: body.stack ?? null,
+ line: body.line ?? null,
+ col: body.col ?? null,
+ errorUrl: body.errorUrl ?? null,
+ });
+ return reply.send({
+ ok: true,
+ sourceMapId,
+ sourceMapLabel: store.labelOf(sourceMapId),
+ frames,
+ } satisfies ResolveResponse);
+ },
+ );
+}
diff --git a/apps/bff/src/http/query/browser-errors.ts b/apps/bff/src/http/query/browser-errors.ts
new file mode 100644
index 0000000..5e78455
--- /dev/null
+++ b/apps/bff/src/http/query/browser-errors.ts
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * `POST /api/layer/:key/browser-errors`
+ *
+ * Wraps OAP's `queryBrowserErrorLogs(BrowserErrorLogQueryCondition)` for
+ * the BROWSER-layer "Browser Errors" tab (#6784). Body shape is
+ * `BrowserErrorsQueryRequest` from `@skywalking-horizon-ui/api-client`.
+ *
+ * Like the logs feed, we accept a `service` NAME on the body and resolve
+ * it to an OAP id server-side, query at SECOND precision (error logs are
+ * event-style — MINUTE rounding would drop the most recent rows), and the
+ * source-map resolution is a separate concern (see admin/source-maps.ts).
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type {
+ BrowserErrorRow,
+ BrowserErrorsQueryRequest,
+ BrowserErrorsResponse,
+ FetchLike,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts, type GraphqlOptions } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
+import { fmtSecond, getServerOffsetMinutes } from '../../util/window.js';
+
+export interface BrowserErrorsRouteDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+ fetch?: FetchLike;
+}
+
+const DEFAULT_WINDOW_MIN = 30;
+const MAX_PAGE_SIZE = 100;
+function clampPageSize(requested: number | undefined, fallback: number): number {
+ if (!Number.isFinite(requested as number) || (requested as number) < 1) return fallback;
+ return Math.min(MAX_PAGE_SIZE, Math.round(requested as number));
+}
+
+function defaultWindow(
+ offsetMinutes: number,
+ minutes?: number,
+ explicit?: { startMs?: number; endMs?: number },
+): { start: string; end: string } {
+ // Absolute range: the UI sends epoch MS (TZ-unambiguous) and the BFF
+ // renders them in OAP-server-local using the OAP offset — same path as
+ // the rolling window. (Sending bare browser-local strings would be read
+ // by OAP as OAP-local and miss the data by the browser↔OAP TZ delta.)
+ if (
+ typeof explicit?.startMs === 'number' &&
+ typeof explicit.endMs === 'number' &&
+ explicit.startMs < explicit.endMs
+ ) {
+ return { start: fmtSecond(explicit.startMs, offsetMinutes), end: fmtSecond(explicit.endMs, offsetMinutes) };
+ }
+ const m =
+ Number.isFinite(minutes) && (minutes as number) > 0
+ ? Math.min(60 * 24 * 7, Math.round(minutes as number))
+ : DEFAULT_WINDOW_MIN;
+ const endMs = Date.now();
+ const startMs = endMs - m * 60_000;
+ return { start: fmtSecond(startMs, offsetMinutes), end: fmtSecond(endMs, offsetMinutes) };
+}
+
+const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
+ query ListServicesForBrowserErrors($layer: String!) {
+ services: listServices(layer: $layer) {
+ id
+ name
+ }
+ }
+`;
+
+const QUERY_BROWSER_ERRORS = /* GraphQL */ `
+ query QueryBrowserErrorLogs($condition: BrowserErrorLogQueryCondition) {
+ data: queryBrowserErrorLogs(condition: $condition) {
+ logs {
+ service
+ serviceVersion
+ time
+ pagePath
+ category
+ grade
+ message
+ line
+ col
+ stack
+ errorUrl
+ firstReportedError
+ }
+ }
+ }
+`;
+
+interface OapBrowserErrorRow {
+ service: string;
+ serviceVersion: string;
+ time: number;
+ pagePath: string;
+ category: BrowserErrorRow['category'];
+ grade?: string | null;
+ message?: string | null;
+ line?: number | null;
+ col?: number | null;
+ stack?: string | null;
+ errorUrl?: string | null;
+ firstReportedError: boolean;
+}
+
+const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/;
+async function resolveServiceId(
+ opts: GraphqlOptions,
+ layer: string,
+ serviceArg: string,
+): Promise<string | null> {
+ if (!serviceArg) return null;
+ if (OAP_SERVICE_ID_RE.test(serviceArg)) return serviceArg;
+ const data = await graphqlPost<{ services: Array<{ id: string; name: string }> }>(
+ opts,
+ LIST_SERVICES_FOR_RESOLVE,
+ { layer: layer.toUpperCase() },
+ );
+ return (
+ data.services.find((s) => s.name === serviceArg)?.id ??
+ data.services.find((s) => s.id === serviceArg)?.id ??
+ null
+ );
+}
+
+interface Body extends BrowserErrorsQueryRequest {
+ service?: string;
+}
+
+export function registerBrowserErrorsRoute(app: FastifyInstance, deps: BrowserErrorsRouteDeps): void {
+ const auth = requireAuth(deps);
+ app.post(
+ '/api/layer/:key/browser-errors',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const params = req.params as { key: string };
+ const layerKey = params.key;
+ if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
+ return reply.code(400).send({ error: 'invalid_layer_key' });
+ }
+ const body = (req.body ?? {}) as Body;
+ const opts = buildOapOpts(deps.config.current, deps.fetch);
+ const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+ const window = defaultWindow(offset, body.windowMinutes, {
+ startMs: body.startMs,
+ endMs: body.endMs,
+ });
+
+ let serviceId = body.serviceId ?? null;
+ if (!serviceId && body.service) {
+ try {
+ serviceId = await resolveServiceId(opts, layerKey, body.service);
+ } catch (err) {
+ return reply.send({
+ generatedAt: Date.now(),
+ query: body,
+ total: 0,
+ logs: [],
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ } satisfies BrowserErrorsResponse);
+ }
+ }
+
+ const condition = {
+ ...(serviceId ? { serviceId } : {}),
+ ...(body.serviceVersionId ? { serviceVersionId: body.serviceVersionId } : {}),
+ ...(body.pagePathId ? { pagePathId: body.pagePathId } : {}),
+ // `ALL` is OAP's "no filter" sentinel — omit it so storage doesn't
+ // try to match a literal category named ALL.
+ ...(body.category && body.category !== 'ALL' ? { category: body.category } : {}),
+ queryDuration: withColdStage(req, { start: window.start, end: window.end, step: 'SECOND' }),
+ paging: {
+ pageNum: Math.max(1, Math.round(body.page ?? 1)),
+ pageSize: clampPageSize(body.pageSize, 50),
+ },
+ };
+
+ try {
+ const env = await graphqlPost<{ data: { logs: OapBrowserErrorRow[] } }>(
+ opts,
+ QUERY_BROWSER_ERRORS,
+ { condition },
+ );
+ const logs: BrowserErrorRow[] = (env.data?.logs ?? []).map((r) => ({
+ service: r.service,
+ serviceVersion: r.serviceVersion,
+ time: r.time,
+ pagePath: r.pagePath,
+ category: r.category,
+ grade: r.grade ?? null,
+ message: r.message ?? null,
+ line: r.line ?? null,
+ col: r.col ?? null,
+ stack: r.stack ?? null,
+ errorUrl: r.errorUrl ?? null,
+ firstReportedError: r.firstReportedError,
+ }));
+ // Normalise to newest-first. OAP's BrowserErrorLog DAO sorts DESC,
+ // but BanyanDB returns it per time-segment (each segment DESC, the
+ // segments concatenated oldest-first), so a multi-segment result is
+ // not globally ordered. Sort by the records' own `time` to guarantee
+ // a strictly newest-first stream.
+ logs.sort((a, b) => b.time - a.time);
+ return reply.send({
+ generatedAt: Date.now(),
+ query: body,
+ total: logs.length,
+ logs,
+ reachable: true,
+ } satisfies BrowserErrorsResponse);
+ } catch (err) {
+ return reply.send({
+ generatedAt: Date.now(),
+ query: body,
+ total: 0,
+ logs: [],
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ } satisfies BrowserErrorsResponse);
+ }
+ },
+ );
+}
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 2d8399e..4a5c3cd 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -62,6 +62,7 @@ function componentsToCaps(components: LayerComponentFlags): LayerCaps {
processTopology: !!components.topology,
traces: !!components.traces,
logs: !!components.logs,
+ browserErrors: !!components.browserErrors,
traceProfiling: !!components.traceProfiling,
ebpfProfiling: !!components.ebpfProfiling,
asyncProfiling: !!components.asyncProfiling,
@@ -202,7 +203,7 @@ const LAYER_DEFAULTS: Record<string, { color: string; slots: LayerSlots; caps: L
MESH_DP: { color: 'var(--sw-info)', slots: { services: 'Data-plane services', instances: 'Sidecars' }, caps: { dashboards: true } },
K8S: { color: 'var(--sw-purple)', slots: { services: 'Workloads', instances: 'Pods' }, caps: { serviceMap: true, dashboards: true, events: true } },
K8S_SERVICE: { color: 'var(--sw-purple)', slots: { services: 'K8s services', instances: 'Pods' }, caps: { serviceMap: true, dashboards: true } },
- BROWSER: { color: 'var(--sw-cyan)', slots: { services: 'Applications', instances: 'Versions', endpoints: 'Pages' }, caps: { dashboards: true, traces: true, logs: true } },
+ BROWSER: { color: 'var(--sw-cyan)', slots: { services: 'Applications', instances: 'Versions', endpoints: 'Pages' }, caps: { dashboards: true, traces: true, browserErrors: true } },
MYSQL: { color: 'var(--sw-warn)', slots: { services: 'Instances' }, caps: { dashboards: true } },
POSTGRESQL: { color: 'var(--sw-warn)', slots: { services: 'Instances' }, caps: { dashboards: true } },
ELASTICSEARCH: { color: 'var(--sw-warn)', slots: { services: 'Clusters', instances: 'Nodes' }, caps: { dashboards: true } },
diff --git a/apps/bff/src/logic/browser-errors/resolve.test.ts b/apps/bff/src/logic/browser-errors/resolve.test.ts
new file mode 100644
index 0000000..f5c7ee4
--- /dev/null
+++ b/apps/bff/src/logic/browser-errors/resolve.test.ts
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, beforeAll } from 'vitest';
+import { transform } from 'esbuild';
+import { TraceMap } from '@jridgewell/trace-mapping';
+import { parseFrames, resolveFrame, resolveStack } from './resolve.js';
+
+// Original source — one meaningful token per line so a resolved position
+// maps to an unambiguous original line. "HELLO_MARKER" is unique so we can
+// find its column in the minified output.
+const SOURCE = [
+ 'function greet(name) {', // line 1
+ ' const greeting = "HELLO_MARKER";', // line 2
+ ' return greeting + name;', // line 3
+ '}', // line 4
+ 'globalThis.__greet = greet;', // line 5
+].join('\n');
+
+let map: TraceMap;
+let minified: string;
+let markerCol0: number; // 0-based column of the marker in the minified line
+
+beforeAll(async () => {
+ // A REAL toolchain-emitted Source Map v3 — esbuild is the build tool, so
+ // this proves the resolver against an authentic map, not a hand fixture.
+ const out = await transform(SOURCE, {
+ loader: 'js',
+ sourcemap: true,
+ minify: true,
+ sourcesContent: true,
+ sourcefile: 'greet.js',
+ });
+ minified = out.code;
+ map = new TraceMap(out.map);
+ markerCol0 = minified.indexOf('HELLO_MARKER');
+ expect(markerCol0).toBeGreaterThanOrEqual(0);
+});
+
+describe('resolveFrame — real esbuild map', () => {
+ it('maps a minified position back to the original source line', () => {
+ // Frames carry 1-based columns (browser convention); resolveFrame
+ // shifts to the 0-based column the map indexes.
+ const frame = { file: 'greet.js', line: 1, col: markerCol0 + 1, fn: 'greet' };
+ const resolved = resolveFrame(map, frame);
+ expect(resolved.original).not.toBeNull();
+ expect(resolved.original?.source).toBe('greet.js');
+ expect(resolved.original?.line).toBe(2);
+ });
+
+ it('includes a source snippet around the mapped line (sourcesContent)', () => {
+ const resolved = resolveFrame(map, { file: 'greet.js', line: 1, col: markerCol0 + 1, fn: null });
+ expect(resolved.snippet).toBeTruthy();
+ expect(resolved.snippet?.lines.join('\n')).toContain('HELLO_MARKER');
+ });
+
+ it('returns original=null for a position outside the map', () => {
+ const resolved = resolveFrame(map, { file: 'greet.js', line: 999_999, col: 1, fn: null });
+ expect(resolved.original).toBeNull();
+ });
+
+ it('returns original=null when the frame has no line', () => {
+ expect(resolveFrame(map, { file: 'x', line: null, col: null, fn: null }).original).toBeNull();
+ });
+});
+
+describe('parseFrames — engine-specific stack formats', () => {
+ const URL = 'https://app.example.com/assets/main.abc123.js';
+
+ it('parses a V8 / Chrome stack', () => {
+ const stack = [
+ 'Error: boom',
+ ` at greet (${URL}:1:845)`,
+ ` at ${URL}:1:870`,
+ ].join('\n');
+ const frames = parseFrames(stack);
+ expect(frames.length).toBe(2);
+ expect(frames[0]).toMatchObject({ file: URL, line: 1, col: 845, fn: 'greet' });
+ expect(frames[1]).toMatchObject({ file: URL, line: 1, col: 870 });
+ });
+
+ it('parses a Firefox / Gecko stack', () => {
+ const stack = [`greet@${URL}:1:845`, `@${URL}:1:870`].join('\n');
+ const frames = parseFrames(stack);
+ expect(frames.length).toBe(2);
+ expect(frames[0]).toMatchObject({ file: URL, line: 1, col: 845 });
+ });
+
+ it('parses a Safari / JSC stack', () => {
+ const stack = [`greet@${URL}:1:845`, `global code@${URL}:1:870`].join('\n');
+ const frames = parseFrames(stack);
+ expect(frames.length).toBe(2);
+ expect(frames[0].file).toBe(URL);
+ expect(frames[0].line).toBe(1);
+ });
+
+ it('returns [] for empty / garbage input', () => {
+ expect(parseFrames(undefined)).toEqual([]);
+ expect(parseFrames('')).toEqual([]);
+ expect(parseFrames('not a stack trace at all')).toEqual([]);
+ });
+});
+
+describe('resolveStack — full pipeline', () => {
+ it('resolves frames parsed from a V8 stack against the map', () => {
+ const stack = `Error: boom\n at greet (main.js:1:${markerCol0 + 1})`;
+ const frames = resolveStack(map, { stack });
+ expect(frames.length).toBe(1);
+ expect(frames[0].original?.line).toBe(2);
+ expect(frames[0].original?.source).toBe('greet.js');
+ });
+
+ it('falls back to the top-level line/col when the stack has no frames (JS category)', () => {
+ const frames = resolveStack(map, { stack: null, line: 1, col: markerCol0 + 1, errorUrl: 'main.js' });
+ expect(frames.length).toBe(1);
+ expect(frames[0].original?.line).toBe(2);
+ });
+
+ it('returns [] when there is neither a parseable stack nor a top-level line', () => {
+ expect(resolveStack(map, { stack: 'garbage', line: null })).toEqual([]);
+ });
+});
diff --git a/apps/bff/src/logic/browser-errors/resolve.ts b/apps/bff/src/logic/browser-errors/resolve.ts
new file mode 100644
index 0000000..2365eda
--- /dev/null
+++ b/apps/bff/src/logic/browser-errors/resolve.ts
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Pure source-map resolution: minified `(file,line,col)` → original
+ * `(source,line,col,name)` + a source snippet. No IO, no store — takes a
+ * parsed `TraceMap` and the error's stack/position, returns frames. This
+ * is the piece that gets fixture-tested with a real esbuild-emitted map.
+ *
+ * The hard part is NOT the mapping — it's that the browser agent ships a
+ * raw `error.stack` STRING whose format is engine-specific (V8 / Gecko /
+ * JSC). We lean on `stacktrace-parser` to normalise those into frames,
+ * then map each frame. When there are no parseable frames we fall back to
+ * the top-level `line`/`col` (only the `JS` category populates those).
+ */
+
+import { originalPositionFor, sourceContentFor, type TraceMap } from '@jridgewell/trace-mapping';
+import { parse as parseStackTrace } from 'stacktrace-parser';
+import type { ResolvedFrame } from '@skywalking-horizon-ui/api-client';
+
+/** A minified frame, columns as they appear in the wire (1-based). */
+export interface GeneratedFrame {
+ file: string | null;
+ line: number | null;
+ col: number | null;
+ fn: string | null;
+}
+
+export interface ResolveInput {
+ stack?: string | null;
+ line?: number | null;
+ col?: number | null;
+ errorUrl?: string | null;
+}
+
+/** Lines of original source kept on each side of the mapped line. */
+const SNIPPET_RADIUS = 2;
+
+/** Split a raw `error.stack` into frames. Returns [] for empty/garbage
+ * input — never throws to the caller. */
+export function parseFrames(stack: string | null | undefined): GeneratedFrame[] {
+ if (!stack) return [];
+ try {
+ return parseStackTrace(stack).map((f) => ({
+ file: f.file ?? null,
+ line: f.lineNumber ?? null,
+ col: f.column ?? null,
+ fn: f.methodName ?? null,
+ }));
+ } catch {
+ return [];
+ }
+}
+
+function snippetFor(
+ map: TraceMap,
+ source: string | null,
+ line: number | null,
+): ResolvedFrame['snippet'] {
+ if (!source || line == null || line < 1) return null;
+ const content = sourceContentFor(map, source);
+ if (!content) return null;
+ const all = content.split('\n');
+ const start = Math.max(1, line - SNIPPET_RADIUS);
+ const end = Math.min(all.length, line + SNIPPET_RADIUS);
+ if (start > all.length) return null;
+ return { startLine: start, lines: all.slice(start - 1, end) };
+}
+
+/** Map one generated frame to its original position. `original` is null
+ * when the frame has no line or falls outside the map. */
+export function resolveFrame(map: TraceMap, frame: GeneratedFrame): ResolvedFrame {
+ if (frame.line == null) return { generated: frame, original: null };
+ // Source maps index columns 0-based; browser stack traces (V8) and the
+ // `window.onerror` `colno` are 1-based. Shift before the lookup or every
+ // result lands one column early.
+ const column = frame.col != null && frame.col > 0 ? frame.col - 1 : 0;
+ const pos = originalPositionFor(map, { line: frame.line, column });
+ if (pos.source == null) return { generated: frame, original: null };
+ return {
+ generated: frame,
+ original: {
+ source: pos.source,
+ line: pos.line ?? null,
+ col: pos.column ?? null,
+ name: pos.name ?? null,
+ },
+ snippet: snippetFor(map, pos.source, pos.line ?? null),
+ };
+}
+
+/** Resolve a whole error: prefer the parsed stack frames; fall back to the
+ * single top-level position when the stack yields nothing. */
+export function resolveStack(map: TraceMap, input: ResolveInput): ResolvedFrame[] {
+ const frames = parseFrames(input.stack);
+ if (frames.length > 0) return frames.map((f) => resolveFrame(map, f));
+ if (input.line != null) {
+ return [
+ resolveFrame(map, {
+ file: input.errorUrl ?? null,
+ line: input.line,
+ col: input.col ?? null,
+ fn: null,
+ }),
+ ];
+ }
+ return [];
+}
diff --git a/apps/bff/src/logic/browser-errors/store.test.ts b/apps/bff/src/logic/browser-errors/store.test.ts
new file mode 100644
index 0000000..31ce60a
--- /dev/null
+++ b/apps/bff/src/logic/browser-errors/store.test.ts
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { mkdtemp, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { SourceMapStore } from './store.js';
+
+/** Distinct, valid Source Map v3 JSON of a controllable size. `seed`
+ * varies the content so each map gets a distinct content-addressed id. */
+function makeMap(seed: string, padBytes = 0): string {
+ return JSON.stringify({
+ version: 3,
+ file: `${seed}.js`,
+ sources: [`${seed}.ts`],
+ names: [],
+ mappings: 'AAAA',
+ sourcesContent: ['x'.repeat(Math.max(1, padBytes))],
+ });
+}
+
+const ONE_MAP = makeMap('a', 40);
+const MAP_BYTES = Buffer.byteLength(ONE_MAP, 'utf8');
+
+function bigBudget() {
+ return { enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 1000 };
+}
+
+describe('SourceMapStore — validation', () => {
+ it('rejects when disabled', () => {
+ const s = new SourceMapStore(() => ({ ...bigBudget(), enabled: false }));
+ expect(s.addUpload('a.map', makeMap('a', 40))).toEqual({ ok: false, error: 'disabled' });
+ });
+
+ it('rejects non-JSON / non-v3 content', () => {
+ const s = new SourceMapStore(() => bigBudget());
+ expect(s.addUpload('a.map', 'not json').ok).toBe(false);
+ expect(s.addUpload('a.map', JSON.stringify({ version: 2, mappings: '' })).ok).toBe(false);
+ expect(s.addUpload('a.map', '').ok).toBe(false);
+ });
+
+ it('rejects a file larger than maxFileBytes', () => {
+ const s = new SourceMapStore(() => ({ ...bigBudget(), maxFileBytes: 20 }));
+ expect(s.addUpload('a.map', makeMap('a', 200))).toEqual({ ok: false, error: 'too_large' });
+ });
+
+ it('content-addresses ids — re-uploading the same file dedups', () => {
+ const s = new SourceMapStore(() => bigBudget());
+ const first = s.addUpload('a.map', ONE_MAP);
+ const again = s.addUpload('a.map', ONE_MAP);
+ expect(first.ok && again.ok && first.map.id === again.map.id).toBe(true);
+ expect(s.list().length).toBe(1);
+ });
+});
+
+describe('SourceMapStore — eviction', () => {
+ it('evicts the least-recently-used upload past the byte budget', () => {
+ // Budget fits two maps but not three.
+ const s = new SourceMapStore(() => ({ ...bigBudget(), maxTotalBytes: MAP_BYTES * 2 + 10 }));
+ const a = s.addUpload('a.map', makeMap('a', 40));
+ const b = s.addUpload('b.map', makeMap('b', 40));
+ const c = s.addUpload('c.map', makeMap('c', 40));
+ expect(a.ok && b.ok && c.ok).toBe(true);
+ if (!a.ok || !b.ok || !c.ok) return;
+ expect(s.has(a.map.id)).toBe(false); // oldest evicted
+ expect(s.has(b.map.id)).toBe(true);
+ expect(s.has(c.map.id)).toBe(true);
+ expect(s.usage().usedBytes).toBeLessThanOrEqual(MAP_BYTES * 2 + 10);
+ });
+
+ it('evicts past the file-count cap independent of bytes', () => {
+ const s = new SourceMapStore(() => ({ ...bigBudget(), maxFileCount: 2 }));
+ const a = s.addUpload('a.map', makeMap('a', 10));
+ s.addUpload('b.map', makeMap('b', 10));
+ s.addUpload('c.map', makeMap('c', 10));
+ expect(a.ok).toBe(true);
+ if (!a.ok) return;
+ expect(s.list().length).toBe(2);
+ expect(s.has(a.map.id)).toBe(false);
+ });
+
+ it('rejects an upload larger than maxTotalBytes (even under maxFileBytes)', () => {
+ const s = new SourceMapStore(() => ({ enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 50, maxFileCount: 100 }));
+ expect(s.addUpload('a.map', makeMap('a', 200))).toEqual({ ok: false, error: 'too_large' });
+ });
+
+ it('lowering maxTotalBytes via hot reload trims resident uploads on next read', () => {
+ const cfg = { enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 };
+ const s = new SourceMapStore(() => cfg);
+ s.addUpload('a.map', makeMap('a', 40));
+ s.addUpload('b.map', makeMap('b', 40));
+ s.addUpload('c.map', makeMap('c', 40));
+ expect(s.list().length).toBe(3);
+ cfg.maxTotalBytes = MAP_BYTES + 5; // room for ~1 map
+ expect(s.list().length).toBe(1); // trimmed on read
+ expect(s.usage().usedBytes).toBeLessThanOrEqual(MAP_BYTES + 5);
+ });
+});
+
+describe('SourceMapStore — uploads vs mounts', () => {
+ it('removes an upload but not a mount; getTraceMap parses both', async () => {
+ const s = new SourceMapStore(() => bigBudget());
+ const up = s.addUpload('app.js.map', ONE_MAP);
+ expect(up.ok).toBe(true);
+ if (!up.ok) return;
+ expect(await s.getTraceMap(up.map.id)).toBeTruthy();
+ expect(s.remove(up.map.id)).toBe(true);
+ expect(s.has(up.map.id)).toBe(false);
+ expect(await s.getTraceMap(up.map.id)).toBeNull();
+
+ const dir = await mkdtemp(join(tmpdir(), 'sm-store-'));
+ await writeFile(join(dir, 'mounted.js.map'), makeMap('mounted', 10), 'utf8');
+ await writeFile(join(dir, 'ignore.txt'), 'not a map', 'utf8');
+ await s.loadMountDir(dir);
+ const mount = s.list().find((d) => d.origin === 'mount');
+ expect(mount).toBeTruthy();
+ if (!mount) return;
+ expect(mount.label).toBe('mounted.js.map');
+ expect(s.remove(mount.id)).toBe(false); // mounts are durable, not deletable
+ expect(await s.getTraceMap(mount.id)).toBeTruthy(); // read from disk on demand
+ });
+
+ it('tolerates a missing mount dir', async () => {
+ const s = new SourceMapStore(() => bigBudget());
+ await expect(s.loadMountDir(join(tmpdir(), 'does-not-exist-xyz'))).resolves.toBeUndefined();
+ expect(s.list().length).toBe(0);
+ });
+
+ it('validates mounted maps at index time — invalid/non-v3 are skipped', async () => {
+ const s = new SourceMapStore(() => bigBudget());
+ const dir = await mkdtemp(join(tmpdir(), 'sm-mount-validate-'));
+ await writeFile(join(dir, 'good.js.map'), makeMap('good', 10), 'utf8');
+ await writeFile(join(dir, 'garbage.js.map'), 'not json at all', 'utf8');
+ await writeFile(join(dir, 'v2.js.map'), JSON.stringify({ version: 2, mappings: '' }), 'utf8');
+ await s.loadMountDir(dir);
+ const mounts = s.list().filter((d) => d.origin === 'mount');
+ expect(mounts.map((m) => m.label)).toEqual(['good.js.map']);
+ });
+
+ it('caps the mounted set at maxFileCount', async () => {
+ const s = new SourceMapStore(() => ({ ...bigBudget(), maxFileCount: 2 }));
+ const dir = await mkdtemp(join(tmpdir(), 'sm-mount-cap-'));
+ for (const seed of ['m1', 'm2', 'm3', 'm4']) {
+ await writeFile(join(dir, `${seed}.js.map`), makeMap(seed, 10), 'utf8');
+ }
+ await s.loadMountDir(dir);
+ expect(s.list().filter((d) => d.origin === 'mount').length).toBe(2);
+ });
+});
+
+describe('SourceMapStore — live config (hot reload)', () => {
+ it('reads enabled + budgets through the getter on every call', () => {
+ const cfg = { enabled: true, maxFileBytes: 1 << 20, maxTotalBytes: 1 << 30, maxFileCount: 100 };
+ const s = new SourceMapStore(() => cfg);
+ expect(s.enabled).toBe(true);
+ expect(s.addUpload('a.map', ONE_MAP).ok).toBe(true);
+ // Flip config live — no reconstruction.
+ cfg.enabled = false;
+ expect(s.enabled).toBe(false);
+ expect(s.addUpload('b.map', makeMap('b', 40))).toEqual({ ok: false, error: 'disabled' });
+ cfg.enabled = true;
+ expect(s.usage().maxFileCount).toBe(100);
+ cfg.maxFileCount = 7;
+ expect(s.usage().maxFileCount).toBe(7);
+ });
+});
diff --git a/apps/bff/src/logic/browser-errors/store.ts b/apps/bff/src/logic/browser-errors/store.ts
new file mode 100644
index 0000000..3197884
--- /dev/null
+++ b/apps/bff/src/logic/browser-errors/store.ts
@@ -0,0 +1,382 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Process-global, in-memory store of JS source maps used to de-obfuscate
+ * BROWSER-layer error stacks (#6784). There is deliberately NO OAP-side
+ * storage — this is a per-BFF-instance cache, and the budgets make it
+ * explicitly evictable so a busy operator can't OOM the relay.
+ *
+ * Two provisioning paths, with different durability:
+ * - `upload` — a `.map` POSTed at runtime. Held in memory only: counts
+ * against the byte/count budget, is LRU-evicted under pressure, and
+ * is gone on restart. This is the "temporary" set the UI warns about.
+ * - `mount` — a `.map` discovered at boot under `bootMountDir`. Indexed
+ * (id + path + size) but NOT held resident; read from disk on demand
+ * and dropped from the parsed cache freely. Durable across restarts,
+ * and not deletable from the UI.
+ *
+ * Resolution itself lives in `resolve.ts` (pure). This module only owns
+ * bytes, eviction, disk IO, and a small parsed-map cache.
+ */
+
+import { readFile, readdir, stat } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+import { basename, join } from 'node:path';
+import { TraceMap } from '@jridgewell/trace-mapping';
+import type {
+ SourceMapDescriptor,
+ SourceMapOrigin,
+ SourceMapUsage,
+} from '@skywalking-horizon-ui/api-client';
+import { logger } from '../../logger.js';
+
+export interface SourceMapStoreOptions {
+ enabled: boolean;
+ maxFileBytes: number;
+ maxTotalBytes: number;
+ maxFileCount: number;
+}
+
+/** Parsed maps run larger than their raw bytes and are only needed during
+ * an active resolve, so they ride a small LRU bounded BOTH by this count
+ * AND by `maxTotalBytes` (using each map's raw bytes as the proxy for its
+ * parsed footprint). That keeps repeated resolves from growing the heap
+ * past the configured budget while the usage bar shows only raw bytes. */
+const PARSED_CACHE_MAX = 8;
+
+interface Entry {
+ id: string;
+ label: string;
+ origin: SourceMapOrigin;
+ bytes: number;
+ addedAt: number;
+ lastUsedAt: number;
+ /** Resident map JSON. Always set for uploads; null for mounts (lazy). */
+ raw: string | null;
+ /** Source path, mounts only. */
+ mountPath?: string;
+}
+
+export type UploadResult =
+ | { ok: true; map: SourceMapDescriptor }
+ | { ok: false; error: 'disabled' | 'too_large' | 'invalid_map' | 'no_file' };
+
+function descriptorOf(e: Entry): SourceMapDescriptor {
+ return {
+ id: e.id,
+ label: e.label,
+ origin: e.origin,
+ bytes: e.bytes,
+ addedAt: e.addedAt,
+ lastUsedAt: e.lastUsedAt,
+ };
+}
+
+/** Cheap structural check that a buffer is a Source Map v3 document.
+ * Returns the decoded JSON on success so callers don't parse twice. */
+function validateSourceMap(text: string): Record<string, unknown> | null {
+ let json: unknown;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ return null;
+ }
+ if (typeof json !== 'object' || json === null) return null;
+ const obj = json as Record<string, unknown>;
+ if (obj.version !== 3) return null;
+ if (typeof obj.mappings !== 'string') return null;
+ return obj;
+}
+
+export class SourceMapStore {
+ /** Config is read through a live getter so `sourceMaps.enabled` + the
+ * budgets hot-reload with horizon.yaml (the repo's general contract).
+ * CAVEAT: the multipart upload hard-cap is registered once at boot in
+ * server.ts, so RAISING `maxFileBytes` at runtime only takes effect for
+ * the in-memory budget — an upload above the startup cap still needs a
+ * restart. Lowering it, and toggling `enabled`, take effect live. */
+ private readonly cfg: () => SourceMapStoreOptions;
+ private readonly entries = new Map<string, Entry>();
+ private readonly parsed = new Map<string, TraceMap>();
+
+ constructor(cfg: () => SourceMapStoreOptions) {
+ this.cfg = cfg;
+ }
+
+ get enabled(): boolean {
+ return this.cfg().enabled;
+ }
+
+ /** Bytes currently resident in memory — uploads (always resident) only.
+ * Mounts are disk-backed and don't count against the runtime budget. */
+ private residentUploadBytes(): number {
+ let sum = 0;
+ for (const e of this.entries.values()) if (e.origin === 'upload') sum += e.bytes;
+ return sum;
+ }
+
+ private uploadCount(): number {
+ let n = 0;
+ for (const e of this.entries.values()) if (e.origin === 'upload') n++;
+ return n;
+ }
+
+ private mountCount(): number {
+ let n = 0;
+ for (const e of this.entries.values()) if (e.origin === 'mount') n++;
+ return n;
+ }
+
+ /** Sum of the RAW bytes of the maps currently held parsed — the proxy
+ * used to bound the parsed cache against `maxTotalBytes`. */
+ private parsedRawBytes(): number {
+ let sum = 0;
+ for (const id of this.parsed.keys()) sum += this.entries.get(id)?.bytes ?? 0;
+ return sum;
+ }
+
+ /** Evict least-recently-used UPLOADS until adding `incomingBytes` keeps
+ * both the byte budget and the count cap. Mounts are never evicted
+ * (they reload from disk). */
+ private evictForUpload(incomingBytes: number): void {
+ const o = this.cfg();
+ const overBudget = (): boolean =>
+ this.residentUploadBytes() + incomingBytes > o.maxTotalBytes;
+ const overCount = (): boolean => this.uploadCount() + 1 > o.maxFileCount;
+ while (overBudget() || overCount()) {
+ let victim: Entry | null = null;
+ for (const e of this.entries.values()) {
+ if (e.origin !== 'upload') continue;
+ if (!victim || e.lastUsedAt < victim.lastUsedAt) victim = e;
+ }
+ if (!victim) break; // nothing left to evict
+ this.drop(victim.id);
+ }
+ }
+
+ private drop(id: string): void {
+ this.entries.delete(id);
+ this.parsed.delete(id);
+ }
+
+ /** Trim resident uploads (LRU) + the parsed cache down to the CURRENT
+ * budgets. Called on the read paths so a hot-reload that LOWERS a budget
+ * takes effect on the next interaction (eviction otherwise only ran on
+ * upload). */
+ private enforceBudget(): void {
+ const o = this.cfg();
+ while (this.residentUploadBytes() > o.maxTotalBytes || this.uploadCount() > o.maxFileCount) {
+ let victim: Entry | null = null;
+ for (const e of this.entries.values()) {
+ if (e.origin !== 'upload') continue;
+ if (!victim || e.lastUsedAt < victim.lastUsedAt) victim = e;
+ }
+ if (!victim) break;
+ this.drop(victim.id);
+ }
+ while (this.parsed.size > PARSED_CACHE_MAX || (this.parsed.size > 0 && this.parsedRawBytes() > o.maxTotalBytes)) {
+ const oldest = this.parsed.keys().next().value as string | undefined;
+ if (oldest === undefined) break;
+ this.parsed.delete(oldest);
+ }
+ }
+
+ /** Add (or refresh) an uploaded map. Id is content-addressed so the
+ * same file re-uploaded dedups rather than piling up. */
+ addUpload(label: string, content: Buffer | string): UploadResult {
+ const o = this.cfg();
+ if (!o.enabled) return { ok: false, error: 'disabled' };
+ const text = typeof content === 'string' ? content : content.toString('utf8');
+ const bytes = Buffer.byteLength(text, 'utf8');
+ if (bytes === 0) return { ok: false, error: 'no_file' };
+ // Reject anything that can't fit the budget at all — a single file
+ // larger than maxFileBytes, or (even alone) larger than maxTotalBytes.
+ if (bytes > o.maxFileBytes || bytes > o.maxTotalBytes) return { ok: false, error: 'too_large' };
+ if (!validateSourceMap(text)) return { ok: false, error: 'invalid_map' };
+
+ const id = createHash('sha1').update(text).digest('hex').slice(0, 12);
+ const now = Date.now();
+ const existing = this.entries.get(id);
+ if (existing) {
+ existing.lastUsedAt = now;
+ existing.label = label || existing.label;
+ return { ok: true, map: descriptorOf(existing) };
+ }
+ this.evictForUpload(bytes);
+ const entry: Entry = {
+ id,
+ label: label || `${id}.map`,
+ origin: 'upload',
+ bytes,
+ addedAt: now,
+ lastUsedAt: now,
+ raw: text,
+ };
+ this.entries.set(id, entry);
+ return { ok: true, map: descriptorOf(entry) };
+ }
+
+ /** Scan `dir` for `.map` files and index them as durable `mount`
+ * entries. Each candidate is read + validated as Source Map v3 at index
+ * time so only real maps surface in the UI (an invalid file used to
+ * appear and only fail at resolve). `maxFileCount` bounds the mounted
+ * set too — once hit, the rest are skipped with a warning (the operator
+ * controls the directory; this just bounds boot I/O + the list). The
+ * validated content is NOT retained — mounts stay disk-backed and are
+ * re-read lazily on resolve, so they don't occupy the byte budget.
+ * Tolerant: a missing dir is a no-op. Safe to call once at boot. */
+ async loadMountDir(dir: string): Promise<void> {
+ const o = this.cfg();
+ if (!o.enabled || !dir) return;
+ let names: string[];
+ try {
+ names = await readdir(dir);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+ logger.info({ dir }, 'source-map mount dir absent; skipping static load');
+ return;
+ }
+ logger.warn({ err, dir }, 'source-map mount dir unreadable');
+ return;
+ }
+ let loaded = 0;
+ let skipped = 0;
+ for (const name of names) {
+ if (!name.endsWith('.map')) continue;
+ if (this.mountCount() >= o.maxFileCount) {
+ skipped++;
+ continue;
+ }
+ const path = join(dir, name);
+ try {
+ const info = await stat(path);
+ if (!info.isFile()) continue;
+ if (info.size > o.maxFileBytes) {
+ logger.warn({ path, size: info.size, cap: o.maxFileBytes }, 'mounted source map exceeds maxFileBytes; skipping');
+ continue;
+ }
+ const text = await readFile(path, 'utf8');
+ if (!validateSourceMap(text)) {
+ logger.warn({ path }, 'mounted file is not a valid Source Map v3; skipping');
+ continue;
+ }
+ const id = createHash('sha1').update(`mount:${path}`).digest('hex').slice(0, 12);
+ const now = Date.now();
+ this.entries.set(id, {
+ id,
+ label: basename(name),
+ origin: 'mount',
+ bytes: info.size,
+ addedAt: now,
+ lastUsedAt: now,
+ raw: null,
+ mountPath: path,
+ });
+ loaded++;
+ } catch (err) {
+ logger.warn({ err, path }, 'failed to index mounted source map');
+ }
+ }
+ if (loaded > 0 || skipped > 0) {
+ logger.info({ dir, loaded, skipped }, 'indexed statically-mounted source maps');
+ }
+ }
+
+ list(): SourceMapDescriptor[] {
+ this.enforceBudget();
+ return [...this.entries.values()]
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt)
+ .map(descriptorOf);
+ }
+
+ usage(): SourceMapUsage {
+ this.enforceBudget();
+ const o = this.cfg();
+ return {
+ usedBytes: this.residentUploadBytes(),
+ maxTotalBytes: o.maxTotalBytes,
+ maxFileBytes: o.maxFileBytes,
+ maxFileCount: o.maxFileCount,
+ };
+ }
+
+ has(id: string): boolean {
+ return this.entries.has(id);
+ }
+
+ labelOf(id: string): string | null {
+ return this.entries.get(id)?.label ?? null;
+ }
+
+ /** Remove an UPLOADED map. Mounts are durable (reload from disk) and
+ * are not deletable. Returns false if absent or a mount. */
+ remove(id: string): boolean {
+ const e = this.entries.get(id);
+ if (!e || e.origin === 'mount') return false;
+ this.drop(id);
+ return true;
+ }
+
+ /** Parse (and cache) the map for `id`, reading a mount file from disk on
+ * demand. Touches `lastUsedAt` so the LRU reflects real usage. Returns
+ * null when the id is unknown or the source is unreadable/invalid. */
+ async getTraceMap(id: string): Promise<TraceMap | null> {
+ const e = this.entries.get(id);
+ if (!e) return null;
+ e.lastUsedAt = Date.now();
+ // Apply any hot-reloaded budget lowering. The just-touched entry is now
+ // MRU, so it's never the eviction victim here.
+ this.enforceBudget();
+ if (!this.entries.has(id)) return null;
+ const cached = this.parsed.get(id);
+ if (cached) {
+ // refresh LRU position
+ this.parsed.delete(id);
+ this.parsed.set(id, cached);
+ return cached;
+ }
+ let text = e.raw;
+ if (text === null && e.mountPath) {
+ try {
+ text = await readFile(e.mountPath, 'utf8');
+ } catch (err) {
+ logger.warn({ err, path: e.mountPath }, 'failed to read mounted source map');
+ return null;
+ }
+ }
+ if (!text) return null;
+ let map: TraceMap;
+ try {
+ map = new TraceMap(text);
+ } catch (err) {
+ logger.warn({ err, id }, 'failed to parse source map');
+ return null;
+ }
+ this.parsed.set(id, map);
+ // Bound the parsed cache by count AND by the raw-byte budget (raw size
+ // as the proxy for parsed footprint), so repeated resolves can't grow
+ // the heap past maxTotalBytes. Never evict the entry we just inserted.
+ const maxTotal = this.cfg().maxTotalBytes;
+ while (this.parsed.size > 1 && (this.parsed.size > PARSED_CACHE_MAX || this.parsedRawBytes() > maxTotal)) {
+ const oldest = this.parsed.keys().next().value as string | undefined;
+ if (oldest === undefined || oldest === id) break;
+ this.parsed.delete(oldest);
+ }
+ return map;
+ }
+}
diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts
index d1eb9a0..6f90172 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -59,6 +59,8 @@ export interface LayerComponentFlags {
topology?: boolean;
traces?: boolean;
logs?: boolean;
+ /** BROWSER-layer JS error logs + source-map de-obfuscation (#6784). */
+ browserErrors?: boolean;
/** Trace-driven thread profiling (original SkyWalking profile). */
traceProfiling?: boolean;
/** Kernel-level CPU / off-CPU profiling via eBPF agents. */
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index 59dc6bf..4b27c31 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -97,6 +97,15 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'GET /api/layer/:key/pod-logs/containers': 'logs:read',
'POST /api/layer/:key/pod-logs': 'logs:read',
+ // ── Browser errors + source maps (#6784) ─────────────────────────
+ // Listing + resolving rides on the read verb; upload/delete of maps
+ // (which sit in BFF memory) needs the dedicated write verb.
+ 'POST /api/layer/:key/browser-errors': 'browser-errors:read',
+ 'GET /api/browser-errors/source-maps': 'browser-errors:read',
+ 'POST /api/browser-errors/resolve': 'browser-errors:read',
+ 'POST /api/browser-errors/source-maps': 'source-map:write',
+ 'DELETE /api/browser-errors/source-maps/:id': 'source-map:write',
+
// ── Topology (read) ──────────────────────────────────────────────
'GET /api/layer/:key/topology': 'topology:read',
'GET /api/layer/:key/instance-topology': 'topology:read',
diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts
index 04da2c3..6692401 100644
--- a/apps/bff/src/rbac/verbs.ts
+++ b/apps/bff/src/rbac/verbs.ts
@@ -28,6 +28,7 @@ export const VERBS = {
alarmsRead: 'alarms:read',
tracesRead: 'traces:read',
logsRead: 'logs:read',
+ browserErrorsRead: 'browser-errors:read',
topologyRead: 'topology:read',
profileRead: 'profile:read',
@@ -49,6 +50,8 @@ export const VERBS = {
alarmRuleWrite: 'alarm-rule:write',
liveDebugRead: 'live-debug:read',
liveDebugWrite: 'live-debug:write',
+ /** Upload / delete browser-error source maps held in BFF memory. */
+ sourceMapWrite: 'source-map:write',
/** Single verb covering task-creation across all profiling families
* (agent / async / pprof / eBPF cpu / eBPF network). Reads ride on
* `*:read` via `profile:read`. */
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index d65ee7a..0e42096 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -20,6 +20,7 @@ import { resolve as resolvePath } from 'node:path';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
+import fastifyMultipart from '@fastify/multipart';
import { AuditLogger } from './audit/logger.js';
import { SessionStore } from './user/sessions.js';
import { LdapHealth } from './user/ldap-health.js';
@@ -43,6 +44,7 @@ import { registerTraceRoutes } from './http/query/trace.js';
import { registerTraceTagRoutes } from './http/query/trace-tag.js';
import { registerZipkinRoutes } from './http/query/zipkin.js';
import { registerLogRoute } from './http/query/log.js';
+import { registerBrowserErrorsRoute } from './http/query/browser-errors.js';
import { registerPodLogRoutes } from './http/query/pod-log.js';
import { registerDashboardQueryRoute } from './http/query/dashboard.js';
import { registerAlarmsQueryRoutes } from './http/query/alarms.js';
@@ -78,11 +80,13 @@ import { registerAlarmRulesRoutes } from './http/admin/alarm-rules.js';
import { registerOverviewTemplatesAdminRoutes } from './http/admin/overview-templates.js';
import { registerAuthStatusRoutes } from './http/admin/auth-status.js';
import { registerAdminUsersRoute } from './http/admin/users.js';
+import { registerSourceMapRoutes } from './http/admin/source-maps.js';
import { registerAuthHealthRoute } from './http/auth-health.js';
import { registerColdStageHook } from './util/duration.js';
// Logic / stores
import { AlarmsStore } from './logic/alarms/store.js';
import { SetupStore } from './logic/setup/store.js';
+import { SourceMapStore } from './logic/browser-errors/store.js';
import { serviceLayerCatalog } from './logic/services/service-layer-catalog.js';
import { HttpError } from './errors.js';
import { logger, loggerOptions } from './logger.js';
@@ -133,6 +137,14 @@ const setupStore = new SetupStore(source.current.setup.file);
await setupStore.load();
const alarmsStore = new AlarmsStore(source.current.alarms.file);
await alarmsStore.load();
+// In-memory source-map cache for the Browser Errors tab (#6784). Process-
+// global (NOT per-session); statically-mounted maps are indexed once here.
+// The store reads config through a live getter so `enabled` + the budgets
+// hot-reload with horizon.yaml. (The multipart hard-cap below is the one
+// exception — it's fixed at registration, so raising maxFileBytes needs a
+// restart; the store still enforces the live value for everything else.)
+const sourceMapStore = new SourceMapStore(() => source.current.sourceMaps);
+await sourceMapStore.loadMountDir(source.current.sourceMaps.bootMountDir);
// Server-global service-by-layer index — shared by the sidebar menu, the
// alarms tagger, and any other surface that needs the service ↔ layer
// mapping. 60s TTL + single-flight dedup; one OAP fan-out per minute
@@ -141,6 +153,13 @@ const serviceLayer = serviceLayerCatalog({ config: source });
await app.register(cookie);
+// Multipart for source-map (.map) uploads on the Browser Errors tab. The
+// fileSize cap mirrors `sourceMaps.maxFileBytes` so an oversized stream is
+// rejected before it's buffered; the store re-checks the live value too.
+await app.register(fastifyMultipart, {
+ limits: { fileSize: source.current.sourceMaps.maxFileBytes, files: 1 },
+});
+
// Text/plain body parser — the rule editor sends raw YAML to /api/rule.
app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body));
@@ -199,6 +218,7 @@ registerTraceRoutes(app, {
registerTraceTagRoutes(app, { config: source, sessions });
registerZipkinRoutes(app, { config: source, sessions });
registerLogRoute(app, { config: source, sessions });
+registerBrowserErrorsRoute(app, { config: source, sessions });
registerPodLogRoutes(app, { config: source, sessions });
registerDashboardQueryRoute(app, {
config: source,
@@ -265,6 +285,7 @@ registerAlarmRulesRoutes(app, { config: source, sessions });
registerOverviewTemplatesAdminRoutes(app, { config: source, sessions });
registerAuthStatusRoutes(app, { config: source, ldapHealth, sessions });
registerAdminUsersRoute(app, { config: source, seenCache });
+registerSourceMapRoutes(app, { config: source, sessions, store: sourceMapStore });
registerTemplateSyncAdminRoutes(app, {
config: source,
sessions,
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 9bee153..af7efad 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -79,6 +79,7 @@ import { LayerApi } from './scopes/layer';
import { TraceApi } from './scopes/trace';
import { ZipkinApi } from './scopes/zipkin';
import { LogApi } from './scopes/log';
+import { BrowserErrorsApi } from './scopes/browser-errors';
import { ProfileApi } from './scopes/profile';
import { EbpfApi } from './scopes/ebpf';
import { NetworkProfileApi } from './scopes/network-profile';
@@ -167,6 +168,17 @@ export type {
LogQueryRequest,
LogsResponse,
LogFacetsResponse,
+ BrowserErrorCategory,
+ BrowserErrorRow,
+ BrowserErrorsQueryRequest,
+ BrowserErrorsResponse,
+ SourceMapDescriptor,
+ SourceMapUsage,
+ SourceMapListResponse,
+ SourceMapUploadResponse,
+ ResolveRequest,
+ ResolvedFrame,
+ ResolveResponse,
ProfileTask,
ProfileTaskLog,
ProfileTaskListResponse,
@@ -280,6 +292,7 @@ export interface AdminLayerTemplate {
deployment?: boolean;
traces?: boolean;
logs?: boolean;
+ browserErrors?: boolean;
podLogs?: boolean;
profiling?: boolean;
traceProfiling?: boolean;
@@ -802,6 +815,53 @@ export class BffClient {
return (await res.text()) as unknown as T;
}
+ /** Multipart variant of {@link request} for file uploads (source-map
+ * `.map` files). Sends a FormData body WITHOUT a content-type header so
+ * the browser sets the multipart boundary; otherwise mirrors request()'s
+ * 401 hook + error envelope. */
+ async requestForm<T>(method: string, path: string, form: FormData): Promise<T> {
+ const init: RequestInit = {
+ method,
+ credentials: 'include',
+ headers: {
+ ...(readColdStageHeader() ? { [COLD_STAGE_HEADER]: '1' } : {}),
+ [LOCALE_HEADER]: currentLocale(),
+ },
+ body: form,
+ };
+ const url = withBase(path);
+ let res: Response;
+ try {
+ res = await fetch(url, init);
+ } catch (err) {
+ const detail = err instanceof Error ? err.message : String(err);
+ pushEvent('api', 'err', `${method} ${path} · network ${detail}`);
+ throw new BffApiError(0, `Cannot reach the server — the BFF is unreachable (${detail}).`, null);
+ }
+ if (res.status === 401) {
+ pushEvent('api', 'info', `${method} ${path} · 401 (re-auth)`);
+ this.on401?.();
+ throw new BffApiError(401, 'unauthenticated', null);
+ }
+ if (!res.ok) {
+ const raw = await res.text().catch(() => '');
+ let parsed: unknown = raw;
+ if (raw) {
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ parsed = raw;
+ }
+ }
+ pushEvent('api', 'err', `${method} ${path} · ${res.status}`);
+ throw new BffApiError(res.status, `${method} ${path} failed (${res.status})`, parsed);
+ }
+ if (res.status === 204) return undefined as T;
+ const ct = res.headers.get('content-type') ?? '';
+ if (ct.includes('application/json')) return (await res.json()) as T;
+ return (await res.text()) as unknown as T;
+ }
+
// ── Sub-clients (one per scope) ───────────────────────────────────
readonly session = new SessionApi(this);
readonly menu = new MenuApi(this);
@@ -811,6 +871,7 @@ export class BffClient {
readonly trace = new TraceApi(this);
readonly zipkin = new ZipkinApi(this);
readonly log = new LogApi(this);
+ readonly browserErrors = new BrowserErrorsApi(this);
readonly profile = new ProfileApi(this);
readonly ebpf = new EbpfApi(this);
readonly networkProfile = new NetworkProfileApi(this);
diff --git a/apps/ui/src/api/scopes/browser-errors.ts b/apps/ui/src/api/scopes/browser-errors.ts
new file mode 100644
index 0000000..9035708
--- /dev/null
+++ b/apps/ui/src/api/scopes/browser-errors.ts
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { BffClient } from '../client';
+import type {
+ BrowserErrorsQueryRequest,
+ BrowserErrorsResponse,
+ ResolveRequest,
+ ResolveResponse,
+ SourceMapListResponse,
+ SourceMapUploadResponse,
+} from '@skywalking-horizon-ui/api-client';
+
+/**
+ * BROWSER-layer error logs + their in-memory source-map cache (#6784).
+ * `list` is the error feed; the source-map calls manage the BFF-resident
+ * maps and `resolve` de-obfuscates a stack against a chosen map.
+ */
+export class BrowserErrorsApi {
+ constructor(private readonly bff: BffClient) {}
+
+ list(
+ layerKey: string,
+ body: BrowserErrorsQueryRequest & { service?: string } = {},
+ ): Promise<BrowserErrorsResponse> {
+ return this.bff.request<BrowserErrorsResponse>(
+ 'POST',
+ `/api/layer/${encodeURIComponent(layerKey)}/browser-errors`,
+ body,
+ );
+ }
+
+ listSourceMaps(): Promise<SourceMapListResponse> {
+ return this.bff.request<SourceMapListResponse>('GET', '/api/browser-errors/source-maps');
+ }
+
+ uploadSourceMap(file: File): Promise<SourceMapUploadResponse> {
+ const form = new FormData();
+ form.append('file', file, file.name);
+ return this.bff.requestForm<SourceMapUploadResponse>('POST', '/api/browser-errors/source-maps', form);
+ }
+
+ deleteSourceMap(id: string): Promise<{ ok: boolean }> {
+ return this.bff.request<{ ok: boolean }>(
+ 'DELETE',
+ `/api/browser-errors/source-maps/${encodeURIComponent(id)}`,
+ );
+ }
+
+ resolve(body: ResolveRequest): Promise<ResolveResponse> {
+ return this.bff.request<ResolveResponse>('POST', '/api/browser-errors/resolve', body);
+ }
+}
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index e63299d..b0a5cb4 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -1652,6 +1652,7 @@ const COMPONENT_TOGGLES: Array<{ key: ComponentKey; label: string; hint: string
{ key: 'endpointDependency', label: 'API dependency', hint: 'Endpoint-to-endpoint dependency view.' },
{ key: 'traces', label: 'Traces', hint: 'Trace explorer scoped to this layer.' },
{ key: 'logs', label: 'Logs', hint: 'Log explorer scoped to this layer.' },
+ { key: 'browserErrors', label: 'Browser Logs', hint: 'BROWSER-layer JS error logs with source-map de-obfuscation of the minified stack.' },
{ key: 'podLogs', label: 'Pod Logs', hint: 'On-demand Kubernetes pod-log live tail. Only K8s-deployed layers (k8s_service, mesh) carry pods that resolve.' },
{ key: 'traceProfiling', label: 'Trace Profiling', hint: 'Trace-driven thread profiling — the original SkyWalking profile.' },
{ key: 'ebpfProfiling', label: 'eBPF Profiling', hint: 'Kernel-level CPU / off-CPU profiling via eBPF agents.' },
@@ -1682,8 +1683,9 @@ const COMPONENT_SCOPE: Record<ComponentKey, AdminScope> = {
deployment: 'deployment',
traces: 'trace',
logs: 'logs',
- // Pod Logs has no editable widget grid — filler to satisfy the
- // exhaustive Record; the menu-preview click no-ops for it.
+ // Browser Errors + Pod Logs have no editable widget grid — filler to
+ // satisfy the exhaustive Record; the menu-preview click no-ops for them.
+ browserErrors: 'logs',
podLogs: 'logs',
traceProfiling: 'traceProfiling',
ebpfProfiling: 'ebpfProfiling',
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 678f88a..d001392 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1357,5 +1357,49 @@
"ungrouped": "ohne Gruppe",
"Scale": "Maßstab",
"Pick a service to see its deployment topology.": "Wählen Sie einen Service, um seine Deployment-Topologie zu sehen.",
- "No deployment topology in this window.": "Keine Deployment-Topologie in diesem Zeitfenster."
+ "No deployment topology in this window.": "Keine Deployment-Topologie in diesem Zeitfenster.",
+ "(anonymous)": "(anonym)",
+ "(no message)": "(keine Meldung)",
+ "(no stack)": "(kein Stack)",
+ "(temp)": "(temp)",
+ "All pages": "Alle Seiten",
+ "All versions": "Alle Versionen",
+ "Backend unreachable.": "Backend nicht erreichbar.",
+ "Browser Logs": "Browser-Logs",
+ "Categories": "Kategorien",
+ "Clear page": "Seite leeren",
+ "Mounted": "Eingebunden",
+ "Next": "Weiter",
+ "No browser logs in this window.": "Keine Browser-Logs in diesem Fenster.",
+ "No logs match the selected category.": "Keine Logs passen zur ausgewählten Kategorie.",
+ "No maps loaded": "Keine Maps geladen",
+ "No source maps loaded. Upload a .map to de-obfuscate stacks.": "Keine Source-Maps geladen. Laden Sie eine .map hoch, um Stacks zu entschlüsseln.",
+ "Page": "Seite",
+ "Pick a source map and resolve to de-obfuscate this stack.": "Wählen Sie eine Source-Map und lösen Sie auf, um diesen Stack zu entschlüsseln.",
+ "Pick a source map…": "Source-Map wählen…",
+ "Prev": "Zurück",
+ "Raw stack": "Roher Stack",
+ "Remove": "Entfernen",
+ "Remove from memory": "Aus Speicher entfernen",
+ "Resolved": "Aufgelöst",
+ "Resolving…": "Wird aufgelöst…",
+ "Search page path": "Seitenpfad suchen",
+ "Select an app to view its browser logs.": "Wählen Sie eine App, um deren Browser-Logs anzuzeigen.",
+ "Service version": "Service-Version",
+ "Source maps": "Source-Maps",
+ "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "Die Source-Map-Entschlüsselung ist auf diesem Server deaktiviert (sourceMaps.enabled: false).",
+ "Upload .map": ".map hochladen",
+ "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Hochgeladene Maps liegen nur im Serverspeicher — bei Speicherdruck werden zuerst die am längsten ungenutzten verworfen, und beim Neustart gehen sie verloren. Für dauerhafte Bereitstellung binden Sie .map-Dateien in [...]
+ "Uploaded · temporary": "Hochgeladen · temporär",
+ "durable": "dauerhaft",
+ "first occurrence": "erstes Auftreten",
+ "no matches": "keine Treffer",
+ "page {page} · showing {shown} of {total} loaded": "Seite {page} · {shown} von {total} geladenen angezeigt",
+ "unmapped ({line}:{col})": "nicht zugeordnet ({line}:{col})",
+ "{count} in window": "{count} im Fenster",
+ "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} Maps · max. {perFile}/Datei",
+ "Upload rejected: {reason}": "Upload abgelehnt: {reason}",
+ "Could not resolve: {reason}": "Konnte nicht aufgelöst werden: {reason}",
+ "{count} logs": "{count} Logs",
+ "grade": "Stufe"
}
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index acb3206..91723dc 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1357,5 +1357,49 @@
"ungrouped": "ungrouped",
"Scale": "Scale",
"Pick a service to see its deployment topology.": "Pick a service to see its deployment topology.",
- "No deployment topology in this window.": "No deployment topology in this window."
+ "No deployment topology in this window.": "No deployment topology in this window.",
+ "(anonymous)": "(anonymous)",
+ "(no message)": "(no message)",
+ "(no stack)": "(no stack)",
+ "(temp)": "(temp)",
+ "All pages": "All pages",
+ "All versions": "All versions",
+ "Backend unreachable.": "Backend unreachable.",
+ "Browser Logs": "Browser Logs",
+ "Categories": "Categories",
+ "Clear page": "Clear page",
+ "Mounted": "Mounted",
+ "Next": "Next",
+ "No browser logs in this window.": "No browser logs in this window.",
+ "No logs match the selected category.": "No logs match the selected category.",
+ "No maps loaded": "No maps loaded",
+ "No source maps loaded. Upload a .map to de-obfuscate stacks.": "No source maps loaded. Upload a .map to de-obfuscate stacks.",
+ "Page": "Page",
+ "Pick a source map and resolve to de-obfuscate this stack.": "Pick a source map and resolve to de-obfuscate this stack.",
+ "Pick a source map…": "Pick a source map…",
+ "Prev": "Prev",
+ "Raw stack": "Raw stack",
+ "Remove": "Remove",
+ "Remove from memory": "Remove from memory",
+ "Resolved": "Resolved",
+ "Resolving…": "Resolving…",
+ "Search page path": "Search page path",
+ "Select an app to view its browser logs.": "Select an app to view its browser logs.",
+ "Service version": "Service version",
+ "Source maps": "Source maps",
+ "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).",
+ "Upload .map": "Upload .map",
+ "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; th [...]
+ "Uploaded · temporary": "Uploaded · temporary",
+ "durable": "durable",
+ "first occurrence": "first occurrence",
+ "no matches": "no matches",
+ "page {page} · showing {shown} of {total} loaded": "page {page} · showing {shown} of {total} loaded",
+ "unmapped ({line}:{col})": "unmapped ({line}:{col})",
+ "{count} in window": "{count} in window",
+ "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} maps · max {perFile}/file",
+ "Upload rejected: {reason}": "Upload rejected: {reason}",
+ "Could not resolve: {reason}": "Could not resolve: {reason}",
+ "{count} logs": "{count} logs",
+ "grade": "grade"
}
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 24490fc..3d2dd20 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1357,5 +1357,49 @@
"ungrouped": "sin agrupar",
"Scale": "Escala",
"Pick a service to see its deployment topology.": "Elige un servicio para ver su topología de despliegue.",
- "No deployment topology in this window.": "No hay topología de despliegue en esta ventana."
+ "No deployment topology in this window.": "No hay topología de despliegue en esta ventana.",
+ "(anonymous)": "(anónimo)",
+ "(no message)": "(sin mensaje)",
+ "(no stack)": "(sin stack)",
+ "(temp)": "(temp)",
+ "All pages": "Todas las páginas",
+ "All versions": "Todas las versiones",
+ "Backend unreachable.": "Backend no accesible.",
+ "Browser Logs": "Logs del navegador",
+ "Categories": "Categorías",
+ "Clear page": "Limpiar página",
+ "Mounted": "Montado",
+ "Next": "Siguiente",
+ "No browser logs in this window.": "No hay logs del navegador en esta ventana.",
+ "No logs match the selected category.": "Ningún log coincide con la categoría seleccionada.",
+ "No maps loaded": "No hay source maps cargados",
+ "No source maps loaded. Upload a .map to de-obfuscate stacks.": "No hay source maps cargados. Sube un .map para restaurar las posiciones originales del stack.",
+ "Page": "Página",
+ "Pick a source map and resolve to de-obfuscate this stack.": "Elige un source map y resuélvelo para restaurar las posiciones originales de este stack.",
+ "Pick a source map…": "Elige un source map…",
+ "Prev": "Anterior",
+ "Raw stack": "Stack original",
+ "Remove": "Quitar",
+ "Remove from memory": "Quitar de la memoria",
+ "Resolved": "Resuelto",
+ "Resolving…": "Resolviendo…",
+ "Search page path": "Buscar ruta de página",
+ "Select an app to view its browser logs.": "Selecciona una app para ver sus logs del navegador.",
+ "Service version": "Versión del servicio",
+ "Source maps": "Source maps",
+ "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "La restauración de posiciones mediante source maps está desactivada en este servidor (sourceMaps.enabled: false).",
+ "Upload .map": "Subir .map",
+ "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Los source maps subidos se guardan solo en la memoria del servidor: se eliminan los menos usados recientemente cuando hay presión de memoria y se pierden al reiniciar. Para un aprovisionamiento duradero, monta los [...]
+ "Uploaded · temporary": "Subido · temporal",
+ "durable": "duradero",
+ "first occurrence": "primera aparición",
+ "no matches": "sin coincidencias",
+ "page {page} · showing {shown} of {total} loaded": "página {page} · mostrando {shown} de {total} cargados",
+ "unmapped ({line}:{col})": "sin mapear ({line}:{col})",
+ "{count} in window": "{count} en la ventana",
+ "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} maps · máx {perFile}/archivo",
+ "Upload rejected: {reason}": "Subida rechazada: {reason}",
+ "Could not resolve: {reason}": "No se pudo resolver: {reason}",
+ "{count} logs": "{count} logs",
+ "grade": "grado"
}
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 371db9c..da2de2b 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1357,5 +1357,49 @@
"ungrouped": "non groupé",
"Scale": "Échelle",
"Pick a service to see its deployment topology.": "Choisissez un service pour voir sa topologie de déploiement.",
- "No deployment topology in this window.": "Aucune topologie de déploiement dans cette fenêtre."
+ "No deployment topology in this window.": "Aucune topologie de déploiement dans cette fenêtre.",
+ "(anonymous)": "(anonyme)",
+ "(no message)": "(aucun message)",
+ "(no stack)": "(aucune pile)",
+ "(temp)": "(temp)",
+ "All pages": "Toutes les pages",
+ "All versions": "Toutes les versions",
+ "Backend unreachable.": "Backend injoignable.",
+ "Browser Logs": "Journaux navigateur",
+ "Categories": "Catégories",
+ "Clear page": "Vider la page",
+ "Mounted": "Monté",
+ "Next": "Suivant",
+ "No browser logs in this window.": "Aucun journal navigateur dans cette fenêtre.",
+ "No logs match the selected category.": "Aucun journal ne correspond à la catégorie sélectionnée.",
+ "No maps loaded": "Aucun source map chargé",
+ "No source maps loaded. Upload a .map to de-obfuscate stacks.": "Aucun source map chargé. Importez un .map pour restaurer les positions d'origine des piles.",
+ "Page": "Page",
+ "Pick a source map and resolve to de-obfuscate this stack.": "Choisissez un source map et résolvez-le pour restaurer cette pile.",
+ "Pick a source map…": "Choisir un source map…",
+ "Prev": "Préc.",
+ "Raw stack": "Pile brute",
+ "Remove": "Supprimer",
+ "Remove from memory": "Retirer de la mémoire",
+ "Resolved": "Résolu",
+ "Resolving…": "Résolution…",
+ "Search page path": "Rechercher un chemin de page",
+ "Select an app to view its browser logs.": "Sélectionnez une application pour voir ses journaux navigateur.",
+ "Service version": "Version du service",
+ "Source maps": "Source maps",
+ "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "La restauration des positions d'origine est désactivée sur ce serveur (sourceMaps.enabled: false).",
+ "Upload .map": "Importer un .map",
+ "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Les source maps importés ne sont conservés qu'en mémoire serveur — les moins récemment utilisés sont évincés en cas de pression mémoire, et tout est perdu au redémarrage. Pour un approvisionnement durable, montez l [...]
+ "Uploaded · temporary": "Importé · temporaire",
+ "durable": "durable",
+ "first occurrence": "première occurrence",
+ "no matches": "aucune correspondance",
+ "page {page} · showing {shown} of {total} loaded": "page {page} · affichage de {shown} sur {total} chargés",
+ "unmapped ({line}:{col})": "non mappé ({line}:{col})",
+ "{count} in window": "{count} dans la fenêtre",
+ "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} maps · max {perFile}/fichier",
+ "Upload rejected: {reason}": "Import refusé : {reason}",
+ "Could not resolve: {reason}": "Résolution impossible : {reason}",
+ "{count} logs": "{count} logs",
+ "grade": "niveau"
}
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 173f22a..9d981d4 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1357,5 +1357,49 @@
"ungrouped": "未分類",
"Scale": "表示倍率",
"Pick a service to see its deployment topology.": "サービスを選択すると、デプロイメントトポロジーが表示されます。",
- "No deployment topology in this window.": "この時間範囲にデプロイメントトポロジーはありません。"
+ "No deployment topology in this window.": "この時間範囲にデプロイメントトポロジーはありません。",
+ "(anonymous)": "(匿名)",
+ "(no message)": "(メッセージなし)",
+ "(no stack)": "(スタックなし)",
+ "(temp)": "(一時)",
+ "All pages": "すべてのページ",
+ "All versions": "すべてのバージョン",
+ "Backend unreachable.": "バックエンドに到達できません。",
+ "Browser Logs": "ブラウザログ",
+ "Categories": "カテゴリ",
+ "Clear page": "ページをクリア",
+ "Mounted": "マウント済み",
+ "Next": "次へ",
+ "No browser logs in this window.": "このウィンドウにブラウザログはありません。",
+ "No logs match the selected category.": "選択したカテゴリに一致するログはありません。",
+ "No maps loaded": "読み込み済みの source map なし",
+ "No source maps loaded. Upload a .map to de-obfuscate stacks.": "source map が読み込まれていません。スタックを復元するには .map をアップロードしてください。",
+ "Page": "ページ",
+ "Pick a source map and resolve to de-obfuscate this stack.": "source map を選択して解決し、このスタックを復元します。",
+ "Pick a source map…": "source map を選択…",
+ "Prev": "前へ",
+ "Raw stack": "生のスタック",
+ "Remove": "削除",
+ "Remove from memory": "メモリから削除",
+ "Resolved": "解決済み",
+ "Resolving…": "解決しています…",
+ "Search page path": "ページパスを検索",
+ "Select an app to view its browser logs.": "ブラウザログを表示するアプリを選択してください。",
+ "Service version": "サービスバージョン",
+ "Source maps": "Source map",
+ "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "このサーバーでは source map による復元が無効です(sourceMaps.enabled: false)。",
+ "Upload .map": ".map をアップロード",
+ "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "アップロードした map はサーバーのメモリ上にのみ保持されます — メモリ逼迫時には最近最も使われていないものから削除され、再起動で失われます。永続的に用意するには、.map ファイルをサーバーの静的 source map ディレクトリにマウントしてください。それらは自動的に再読み込みされ、ここからは削除できません。",
+ "Uploaded · temporary": "アップロード済み · 一時",
+ "durable": "永続",
+ "first occurrence": "初回発生",
+ "no matches": "該当なし",
+ "page {page} · showing {shown} of {total} loaded": "ページ {page} · 読み込み済み {total} 件中 {shown} 件を表示",
+ "unmapped ({line}:{col})": "未マップ ({line}:{col})",
+ "{count} in window": "ウィンドウ内に {count} 件",
+ "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} 個の map · ファイルあたり最大 {perFile}",
+ "Upload rejected: {reason}": "アップロードが拒否されました:{reason}",
+ "Could not resolve: {reason}": "解決できませんでした:{reason}",
+ "{count} logs": "{count} 件のログ",
+ "grade": "グレード"
}
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index f74d400..d700d21 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1357,5 +1357,49 @@
"ungrouped": "미분류",
"Scale": "배율",
"Pick a service to see its deployment topology.": "서비스를 선택하면 배포 토폴로지가 표시됩니다.",
- "No deployment topology in this window.": "이 기간에 배포 토폴로지가 없습니다."
+ "No deployment topology in this window.": "이 기간에 배포 토폴로지가 없습니다.",
+ "(anonymous)": "(익명)",
+ "(no message)": "(메시지 없음)",
+ "(no stack)": "(스택 없음)",
+ "(temp)": "(임시)",
+ "All pages": "전체 페이지",
+ "All versions": "전체 버전",
+ "Backend unreachable.": "백엔드에 연결할 수 없습니다.",
+ "Browser Logs": "브라우저 로그",
+ "Categories": "카테고리",
+ "Clear page": "페이지 비우기",
+ "Mounted": "마운트됨",
+ "Next": "다음",
+ "No browser logs in this window.": "이 구간에 브라우저 로그가 없습니다.",
+ "No logs match the selected category.": "선택한 카테고리에 해당하는 로그가 없습니다.",
+ "No maps loaded": "로드된 맵 없음",
+ "No source maps loaded. Upload a .map to de-obfuscate stacks.": "로드된 소스 맵이 없습니다. .map을 업로드해 스택의 원본 위치를 복원하세요.",
+ "Page": "페이지",
+ "Pick a source map and resolve to de-obfuscate this stack.": "소스 맵을 선택하고 변환해 이 스택의 원본 위치를 복원하세요.",
+ "Pick a source map…": "소스 맵 선택…",
+ "Prev": "이전",
+ "Raw stack": "원본 스택",
+ "Remove": "제거",
+ "Remove from memory": "메모리에서 제거",
+ "Resolved": "변환됨",
+ "Resolving…": "변환 중…",
+ "Search page path": "페이지 경로 검색",
+ "Select an app to view its browser logs.": "브라우저 로그를 보려면 앱을 선택하세요.",
+ "Service version": "서비스 버전",
+ "Source maps": "소스 맵",
+ "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "이 서버에서는 소스 맵 기반 원본 위치 복원이 비활성화되어 있습니다(sourceMaps.enabled: false).",
+ "Upload .map": ".map 업로드",
+ "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "업로드된 맵은 서버 메모리에만 보관되며, 메모리가 부족하면 가장 오래 사용되지 않은 것부터 제거되고 재시작 시 사라집니다. 영구적으로 보관하려면 .map 파일을 서버의 정적 소스 맵 디렉터리에 마운트하세요. 해당 파일은 자동으로 다시 로드되며 여기서 삭제할 수 없습니다.",
+ "Uploaded · temporary": "업로드됨 · 임시",
+ "durable": "영구",
+ "first occurrence": "최초 발생",
+ "no matches": "일치 항목 없음",
+ "page {page} · showing {shown} of {total} loaded": "{page}페이지 · 로드된 {total}개 중 {shown}개 표시",
+ "unmapped ({line}:{col})": "매핑 안 됨 ({line}:{col})",
+ "{count} in window": "구간 내 {count}개",
+ "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · 맵 {count}개 · 파일당 최대 {perFile}",
+ "Upload rejected: {reason}": "업로드가 거부되었습니다: {reason}",
+ "Could not resolve: {reason}": "변환할 수 없습니다: {reason}",
+ "{count} logs": "로그 {count}개",
+ "grade": "등급"
}
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 829e027..5c16445 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1357,5 +1357,49 @@
"ungrouped": "sem grupo",
"Scale": "Escala",
"Pick a service to see its deployment topology.": "Escolha um serviço para ver sua topologia de implantação.",
- "No deployment topology in this window.": "Nenhuma topologia de implantação nesta janela."
+ "No deployment topology in this window.": "Nenhuma topologia de implantação nesta janela.",
+ "(anonymous)": "(anônimo)",
+ "(no message)": "(sem mensagem)",
+ "(no stack)": "(sem stack)",
+ "(temp)": "(temp)",
+ "All pages": "Todas as páginas",
+ "All versions": "Todas as versões",
+ "Backend unreachable.": "Backend inacessível.",
+ "Browser Logs": "Logs do navegador",
+ "Categories": "Categorias",
+ "Clear page": "Limpar página",
+ "Mounted": "Montado",
+ "Next": "Próximo",
+ "No browser logs in this window.": "Nenhum log de navegador nesta janela.",
+ "No logs match the selected category.": "Nenhum log corresponde à categoria selecionada.",
+ "No maps loaded": "Nenhum mapa carregado",
+ "No source maps loaded. Upload a .map to de-obfuscate stacks.": "Nenhum source map carregado. Envie um .map para restaurar as posições originais dos stacks.",
+ "Page": "Página",
+ "Pick a source map and resolve to de-obfuscate this stack.": "Escolha um source map e resolva para restaurar as posições originais deste stack.",
+ "Pick a source map…": "Escolha um source map…",
+ "Prev": "Anterior",
+ "Raw stack": "Stack bruto",
+ "Remove": "Remover",
+ "Remove from memory": "Remover da memória",
+ "Resolved": "Resolvido",
+ "Resolving…": "Resolvendo…",
+ "Search page path": "Buscar caminho da página",
+ "Select an app to view its browser logs.": "Selecione um app para ver seus logs de navegador.",
+ "Service version": "Versão do serviço",
+ "Source maps": "Source maps",
+ "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "A restauração de posições via source map está desativada neste servidor (sourceMaps.enabled: false).",
+ "Upload .map": "Enviar .map",
+ "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "Os mapas enviados ficam apenas na memória do servidor — os menos usados recentemente são descartados sob pressão de memória e perdidos ao reiniciar. Para provisionamento durável, monte os arquivos .map no diretório [...]
+ "Uploaded · temporary": "Enviado · temporário",
+ "durable": "durável",
+ "first occurrence": "primeira ocorrência",
+ "no matches": "nenhuma correspondência",
+ "page {page} · showing {shown} of {total} loaded": "página {page} · mostrando {shown} de {total} carregados",
+ "unmapped ({line}:{col})": "não mapeado ({line}:{col})",
+ "{count} in window": "{count} na janela",
+ "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} mapas · máx {perFile}/arquivo",
+ "Upload rejected: {reason}": "Envio rejeitado: {reason}",
+ "Could not resolve: {reason}": "Não foi possível resolver: {reason}",
+ "{count} logs": "{count} logs",
+ "grade": "grau"
}
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 47352a8..427a98e 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1357,5 +1357,49 @@
"ungrouped": "未分组",
"Scale": "缩放比例",
"Pick a service to see its deployment topology.": "选择一个服务以查看其部署拓扑。",
- "No deployment topology in this window.": "此时间窗口内没有部署拓扑。"
+ "No deployment topology in this window.": "此时间窗口内没有部署拓扑。",
+ "(anonymous)": "(匿名)",
+ "(no message)": "(无消息)",
+ "(no stack)": "(无调用栈)",
+ "(temp)": "(临时)",
+ "All pages": "全部页面",
+ "All versions": "全部版本",
+ "Backend unreachable.": "后端不可达。",
+ "Browser Logs": "浏览器日志",
+ "Categories": "类别",
+ "Clear page": "清空页面",
+ "Mounted": "已挂载",
+ "Next": "下一页",
+ "No browser logs in this window.": "此时间窗口内无浏览器日志。",
+ "No logs match the selected category.": "没有日志匹配所选类别。",
+ "No maps loaded": "未加载 source map",
+ "No source maps loaded. Upload a .map to de-obfuscate stacks.": "尚未加载 source map。上传 .map 文件以还原调用栈的原始位置。",
+ "Page": "页面",
+ "Pick a source map and resolve to de-obfuscate this stack.": "选择一个 source map 并解析,以还原此调用栈的原始位置。",
+ "Pick a source map…": "选择一个 source map…",
+ "Prev": "上一页",
+ "Raw stack": "原始调用栈",
+ "Remove": "移除",
+ "Remove from memory": "从内存中移除",
+ "Resolved": "已解析",
+ "Resolving…": "解析中…",
+ "Search page path": "搜索页面路径",
+ "Select an app to view its browser logs.": "选择一个应用以查看其浏览器日志。",
+ "Service version": "服务版本",
+ "Source maps": "Source maps",
+ "Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).": "此服务器已禁用 source map 原始位置还原(sourceMaps.enabled: false)。",
+ "Upload .map": "上传 .map",
+ "Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server's static source-map directory; those are reloaded automatically and can't be deleted here.": "上传的 map 仅保存在服务器内存中 —— 内存紧张时按最近最少使用优先淘汰,重启后丢失。如需持久化配置,请将 .map 文件挂载到服务器的静态 source-map 目录;这些文件会自动重新加载,且无法在此删除。",
+ "Uploaded · temporary": "已上传 · 临时",
+ "durable": "持久",
+ "first occurrence": "首次出现",
+ "no matches": "无匹配",
+ "page {page} · showing {shown} of {total} loaded": "第 {page} 页 · 显示已加载 {total} 条中的 {shown} 条",
+ "unmapped ({line}:{col})": "未映射({line}:{col})",
+ "{count} in window": "时间窗口内 {count} 条",
+ "{used} / {total} · {count} maps · max {perFile}/file": "{used} / {total} · {count} 个 map · 每文件上限 {perFile}",
+ "Upload rejected: {reason}": "上传被拒绝:{reason}",
+ "Could not resolve: {reason}": "无法解析:{reason}",
+ "{count} logs": "{count} 条日志",
+ "grade": "级别"
}
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index db5eebe..2c4a06e 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -184,6 +184,7 @@ const SCOPE_CAP_PREDICATE: Record<string, (L: LayerDef) => boolean> = {
dependency: (L) => Boolean(L.caps?.endpointDependency),
trace: (L) => Boolean(L.caps?.traces),
logs: (L) => Boolean(L.caps?.logs),
+ 'browser-errors': (L) => Boolean(L.caps?.browserErrors),
'trace-profiling': (L) => Boolean(L.caps?.traceProfiling),
'ebpf-profiling': (L) => Boolean(L.caps?.ebpfProfiling),
'async-profiling': (L) => Boolean(L.caps?.asyncProfiling),
diff --git a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
new file mode 100644
index 0000000..003e454
--- /dev/null
+++ b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
@@ -0,0 +1,1005 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Per-layer BROWSER "Browser Logs" tab (#6784). Lists JS error logs from
+ OAP and de-obfuscates the minified stack against an operator-chosen
+ source map (held in BFF memory). Visual vocabulary is deliberately the
+ SAME as the general Logs tab — category legend + counts, time-bucketed
+ density histogram, dense stream rows — so the two read identically. The
+ one addition is the row-expand panel: raw stack + source-map resolution.
+-->
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import type {
+ BrowserErrorCategory,
+ BrowserErrorRow,
+ LayerDef,
+ ResolveResponse,
+ SourceMapDescriptor,
+ SourceMapUsage,
+} from '@/api/client';
+import { bffClient, describeApiError } from '@/api/client';
+import { useLayers } from '@/shell/useLayers';
+import { useSetupStore } from '@/state/setup';
+import { useSelectedService } from '@/layer/useSelectedService';
+import { useLayerLanding } from '@/layer/useLayerLanding';
+import { useLayerServiceName } from '@/layer/useLayerServiceName';
+import { useLayerInstances } from '@/layer/useLayerInstances';
+import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
+import { useLayerBrowserErrors } from '@/layer/browser-errors/useLayerBrowserErrors';
+import SourceMapManager from '@/layer/browser-errors/SourceMapManager.vue';
+
+const route = useRoute();
+const { t } = useI18n({ useScope: 'global' });
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key === layerKey.value) ?? null);
+const setup = useSetupStore();
+const { selectedId, setSelected } = useSelectedService();
+const safeLayer = computed<LayerDef>(
+ () =>
+ layer.value ?? {
+ key: layerKey.value,
+ name: layerKey.value,
+ color: 'var(--sw-fg-2)',
+ serviceCount: -1,
+ active: false,
+ level: null,
+ slots: {},
+ caps: {},
+ },
+);
+const safeCfg = computed(() => {
+ if (!layer.value) return { priority: 99, topN: 5, orderBy: 'cpm', columns: [], style: 'table' as const };
+ return setup.ensure(layer.value.key, {
+ slots: layer.value.slots,
+ caps: layer.value.caps,
+ metrics: layer.value.metrics,
+ overview: layer.value.overview,
+ }).landing;
+});
+const landing = useLayerLanding(safeLayer, safeCfg);
+const serviceName = useLayerServiceName(layerKey, landing);
+const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []);
+watch(
+ landingRows,
+ (rows) => {
+ const first = rows[0];
+ if (first && !selectedId.value) setSelected(first.serviceId);
+ },
+ { immediate: true },
+);
+
+// ── Time range (own to this triage page — the topbar pauses the global
+// ticker here) + paging. Preset window OR a Custom absolute range, exactly
+// like the Logs conditions bar. ─────────────────────────────────────────
+const TIME_RANGE_PRESETS: Array<{ label: string; minutes: number }> = [
+ { label: 'Last 15 min', minutes: 15 },
+ { label: 'Last 30 min', minutes: 30 },
+ { label: 'Last 1 hour', minutes: 60 },
+ { label: 'Last 3 hours', minutes: 180 },
+ { label: 'Last 6 hours', minutes: 360 },
+ { label: 'Last 12 hours', minutes: 720 },
+ { label: 'Last 24 hours', minutes: 1440 },
+];
+const CUSTOM_RANGE_SENTINEL = -1;
+const windowMinutes = ref<number>(30);
+const customStart = ref<string | null>(null);
+const customEnd = ref<string | null>(null);
+const isCustomRange = computed(() => windowMinutes.value === CUSTOM_RANGE_SENTINEL);
+function fmtDateTimeLocal(d: Date): string {
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+watch(isCustomRange, (custom) => {
+ if (custom) {
+ if (!customStart.value || !customEnd.value) {
+ const end = new Date();
+ const start = new Date(end.getTime() - 30 * 60_000);
+ customStart.value = fmtDateTimeLocal(start);
+ customEnd.value = fmtDateTimeLocal(end);
+ }
+ } else {
+ customStart.value = null;
+ customEnd.value = null;
+ }
+});
+// Send the custom range as epoch MS — the datetime-local inputs are
+// browser-local, so `new Date(value)` yields the correct absolute instant.
+// The BFF renders it in OAP-server-local using the OAP offset; sending a
+// bare local string would be misread as OAP-local and miss by the TZ delta.
+const startMsRef = computed<number | null>(() =>
+ isCustomRange.value && customStart.value ? new Date(customStart.value).getTime() : null,
+);
+const endMsRef = computed<number | null>(() =>
+ isCustomRange.value && customEnd.value ? new Date(customEnd.value).getTime() : null,
+);
+const windowMinutesEffective = computed<number>(() => (isCustomRange.value ? 0 : windowMinutes.value));
+const page = ref(1);
+const pageSize = ref(100);
+// The query always pulls every category; the legend filters the stream
+// client-side (mirrors the Logs legend) so the chips can show full
+// per-category counts regardless of which one is selected.
+const allCategories = ref<BrowserErrorCategory>('ALL');
+
+// Server-side filters (mirror booster-ui's Version + Page conditions):
+// the BROWSER "Versions" are instances → serviceVersionId; "Pages" are
+// endpoints → pagePathId. Reuse the shared layer instance/endpoint feeds.
+const selectedVersionId = ref('');
+const { instances: versionList } = useLayerInstances(layerKey, serviceName);
+
+// Page (endpoint) is a searchable combobox, not a plain dropdown: OAP
+// returns only a top-N endpoint list, so the typed keyword is forwarded to
+// the endpoint search (`pageQuery`) to surface pages outside the top-N.
+const selectedPageId = ref('');
+const selectedPageLabel = ref('');
+const pageSearchInput = ref('');
+const pageComboOpen = ref(false);
+const pageComboEl = ref<HTMLElement | null>(null);
+const pageQuery = ref('');
+const pageLimit = ref(50);
+const { endpoints: pageList } = useLayerEndpoints(layerKey, serviceName, pageQuery, pageLimit);
+function onPageInput(ev: Event): void {
+ const v = (ev.target as HTMLInputElement).value;
+ pageSearchInput.value = v;
+ pageQuery.value = v.trim();
+ pageComboOpen.value = true;
+}
+function pickPage(p: { id: string; name: string }): void {
+ selectedPageId.value = p.id;
+ selectedPageLabel.value = p.name;
+ pageSearchInput.value = '';
+ pageQuery.value = '';
+ pageComboOpen.value = false;
+}
+function clearPage(): void {
+ selectedPageId.value = '';
+ selectedPageLabel.value = '';
+ pageSearchInput.value = '';
+ pageQuery.value = '';
+ pageComboOpen.value = false;
+}
+function onPageComboClickOutside(ev: MouseEvent): void {
+ if (!pageComboOpen.value) return;
+ const el = pageComboEl.value;
+ if (el && !el.contains(ev.target as Node)) pageComboOpen.value = false;
+}
+onMounted(() => window.addEventListener('click', onPageComboClickOutside));
+onBeforeUnmount(() => window.removeEventListener('click', onPageComboClickOutside));
+
+const { logs, total, reachable, queryError, isFetching, refetch } = useLayerBrowserErrors(layerKey, {
+ service: serviceName,
+ serviceVersionId: selectedVersionId,
+ pagePathId: selectedPageId,
+ category: allCategories,
+ page,
+ pageSize,
+ windowMinutes: windowMinutesEffective,
+ startMs: startMsRef,
+ endMs: endMsRef,
+});
+
+// Reset the version/page filters when the app changes (their ids belong to
+// the previous service), and reset paging on any condition change.
+watch(serviceName, () => {
+ selectedVersionId.value = '';
+ clearPage();
+});
+watch([serviceName, windowMinutes, customStart, customEnd, selectedVersionId, selectedPageId], () => {
+ page.value = 1;
+});
+// Collapse the open row + its resolution whenever a fresh result set
+// lands. (Category-filter toggles reset it too — see toggleCat — because
+// `expanded` is an index into `filteredLogs`, which changes when the
+// filter changes.)
+watch(logs, () => {
+ closeExpanded();
+});
+
+const hasMorePages = computed(() => logs.value.length >= pageSize.value);
+
+// ── Categories: legend + histogram colours (uses the same tokens the
+// Logs view uses for level colours, so the two tabs share a palette). ─
+const CATEGORY_ORDER = ['js', 'promise', 'vue', 'ajax', 'resource', 'unknown'] as const;
+type Cat = (typeof CATEGORY_ORDER)[number];
+const CATEGORY_COLOR: Record<Cat, string> = {
+ js: 'var(--sw-err)',
+ promise: 'var(--sw-warn)',
+ vue: 'var(--sw-info)',
+ ajax: 'var(--sw-accent-2)',
+ resource: 'var(--sw-cyan)',
+ unknown: 'var(--sw-fg-3)',
+};
+function catOf(r: BrowserErrorRow): Cat {
+ const c = (r.category ?? '').toLowerCase();
+ return (CATEGORY_ORDER as readonly string[]).includes(c) ? (c as Cat) : 'unknown';
+}
+
+const selectedCat = ref<Cat | null>(null);
+function toggleCat(c: Cat): void {
+ selectedCat.value = selectedCat.value === c ? null : c;
+ // `expanded` indexes into filteredLogs, which just changed — drop it.
+ closeExpanded();
+}
+const filteredLogs = computed<BrowserErrorRow[]>(() =>
+ selectedCat.value ? logs.value.filter((r) => catOf(r) === selectedCat.value) : logs.value,
+);
+const catFacet = computed<Record<Cat, number>>(() => {
+ const counts = { js: 0, promise: 0, vue: 0, ajax: 0, resource: 0, unknown: 0 } as Record<Cat, number>;
+ for (const r of logs.value) counts[catOf(r)] += 1;
+ return counts;
+});
+
+// ── Density histogram (60 bins) — counts per category over the loaded
+// rows' time window, same construction as the Logs tab. ──────────────
+const BINS = 60;
+const histogram = computed(() => {
+ const rows = logs.value;
+ if (rows.length === 0) return { bins: [] as Array<Record<Cat, number>>, max: 0, t0: 0, t1: 0 };
+ let t0 = Infinity;
+ let t1 = -Infinity;
+ for (const r of rows) {
+ if (r.time < t0) t0 = r.time;
+ if (r.time > t1) t1 = r.time;
+ }
+ if (!Number.isFinite(t0) || !Number.isFinite(t1) || t0 === t1) {
+ t0 = (t1 || Date.now()) - 60_000;
+ t1 = t1 || Date.now();
+ }
+ const span = t1 - t0 || 1;
+ const bins: Array<Record<Cat, number>> = Array.from({ length: BINS }, () => ({
+ js: 0, promise: 0, vue: 0, ajax: 0, resource: 0, unknown: 0,
+ }));
+ for (const r of rows) {
+ const idx = Math.min(BINS - 1, Math.floor(((r.time - t0) / span) * BINS));
+ bins[idx][catOf(r)] += 1;
+ }
+ let max = 0;
+ for (const b of bins) {
+ const t = b.js + b.promise + b.vue + b.ajax + b.resource + b.unknown;
+ if (t > max) max = t;
+ }
+ return { bins, max, t0, t1 };
+});
+const hoveredBin = ref<number | null>(null);
+function binTotal(b: Record<Cat, number>): number {
+ return b.js + b.promise + b.vue + b.ajax + b.resource + b.unknown;
+}
+
+function pad(n: number): string {
+ return String(n).padStart(2, '0');
+}
+function fmtTime(ts: number): string {
+ const d = new Date(ts);
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`;
+}
+function fmtDate(ts: number): string {
+ const d = new Date(ts);
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+function fmtAxisTime(ms: number): string {
+ const d = new Date(ms);
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+function fmtBucketRange(i: number, t0: number, t1: number): string {
+ const span = (t1 - t0) || 1;
+ const a = t0 + (span * i) / BINS;
+ const b = t0 + (span * (i + 1)) / BINS;
+ const t = (ms: number) => {
+ const d = new Date(ms);
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+ };
+ return `${t(a)} – ${t(b)}`;
+}
+
+// ── Source-map cache (shared by the manager + the per-row picker) ───
+const showMaps = ref(false);
+const sourceMaps = ref<SourceMapDescriptor[]>([]);
+const usage = ref<SourceMapUsage | null>(null);
+const mapsEnabled = ref(true);
+const mapsBusy = ref(false);
+const mapsError = ref<string | null>(null);
+
+async function loadMaps(): Promise<void> {
+ try {
+ const res = await bffClient.browserErrors.listSourceMaps();
+ sourceMaps.value = res.maps;
+ usage.value = res.usage;
+ mapsEnabled.value = res.enabled;
+ mapsError.value = null;
+ } catch (err) {
+ mapsError.value = describeApiError(err);
+ }
+}
+async function onUpload(file: File): Promise<void> {
+ mapsBusy.value = true;
+ mapsError.value = null;
+ try {
+ const res = await bffClient.browserErrors.uploadSourceMap(file);
+ if (!res.ok) mapsError.value = t('Upload rejected: {reason}', { reason: res.error ?? t('unknown') });
+ await loadMaps();
+ if (res.ok && res.map && !selectedMapId.value) selectedMapId.value = res.map.id;
+ } catch (err) {
+ mapsError.value = describeApiError(err);
+ } finally {
+ mapsBusy.value = false;
+ }
+}
+async function onRemove(id: string): Promise<void> {
+ mapsBusy.value = true;
+ try {
+ await bffClient.browserErrors.deleteSourceMap(id);
+ if (selectedMapId.value === id) selectedMapId.value = '';
+ await loadMaps();
+ } catch (err) {
+ mapsError.value = describeApiError(err);
+ } finally {
+ mapsBusy.value = false;
+ }
+}
+onMounted(loadMaps);
+
+// ── Row expand + source-map resolution (one row open at a time) ─────
+const expanded = ref<number | null>(null);
+const selectedMapId = ref<string>('');
+const resolved = ref<ResolveResponse | null>(null);
+const resolveBusy = ref(false);
+const resolveErr = ref<string | null>(null);
+
+// idx is part of the key so rows stay uniquely keyed even when the demo
+// reports several errors at the identical timestamp+page+version (a
+// data-only key would collide). Same approach as the Logs stream.
+function rowKey(r: BrowserErrorRow, idx: number): string {
+ return `${r.time}-${r.category}-${idx}`;
+}
+function closeExpanded(): void {
+ expanded.value = null;
+ resolved.value = null;
+ resolveErr.value = null;
+}
+function toggleRow(idx: number): void {
+ if (expanded.value === idx) {
+ closeExpanded();
+ return;
+ }
+ expanded.value = idx;
+ resolved.value = null;
+ resolveErr.value = null;
+ if (!selectedMapId.value && sourceMaps.value.length > 0) selectedMapId.value = sourceMaps.value[0].id;
+}
+async function resolveRow(row: BrowserErrorRow): Promise<void> {
+ if (!selectedMapId.value) return;
+ resolveBusy.value = true;
+ resolveErr.value = null;
+ resolved.value = null;
+ try {
+ const res = await bffClient.browserErrors.resolve({
+ stack: row.stack ?? undefined,
+ line: row.line ?? undefined,
+ col: row.col ?? undefined,
+ errorUrl: row.errorUrl ?? undefined,
+ category: row.category,
+ sourceMapId: selectedMapId.value,
+ });
+ resolved.value = res;
+ if (!res.ok) resolveErr.value = t('Could not resolve: {reason}', { reason: res.error ?? t('unknown') });
+ } catch (err) {
+ resolveErr.value = describeApiError(err);
+ } finally {
+ resolveBusy.value = false;
+ }
+}
+function loc(row: BrowserErrorRow): string {
+ // Browser line/col are 1-based; OAP reports 0 for categories that carry
+ // no top-level position (PROMISE/AJAX/…), so a positive line is the
+ // signal that a position is real. Avoids "0:0" littering every row.
+ if (!row.line) return '';
+ return `${row.line}:${row.col ?? 0}`;
+}
+</script>
+
+<template>
+ <div class="lg-tab">
+ <!-- Conditions card — same shape/vocabulary as the Logs conditions
+ bar (kicker + Run query head, label-on-top field grid). Source
+ maps sits on the right of the head; its panel expands below. -->
+ <section class="lg-toolbar sw-card">
+ <div class="lg-toolbar-head">
+ <span class="kicker">{{ t('Browser Logs') }}</span>
+ <div class="be-head-right">
+ <button type="button" class="be-maps-toggle" @click="showMaps = !showMaps">
+ <span class="be-caret">{{ showMaps ? '▾' : '▸' }}</span>
+ {{ t('Source maps') }}
+ <span class="be-maps-count">{{ sourceMaps.length }}</span>
+ </button>
+ <button class="sw-btn primary" type="button" :disabled="isFetching" @click="refetch()">{{ t('Run query') }}</button>
+ </div>
+ </div>
+ <div class="lg-conditions">
+ <label class="cf">
+ <span>{{ t('Version') }}</span>
+ <select v-model="selectedVersionId" class="cf-input" :disabled="!serviceName" :title="t('Service version')">
+ <option value="">{{ t('All versions') }}</option>
+ <option v-for="v in versionList" :key="v.id" :value="v.id">{{ v.name }}</option>
+ </select>
+ </label>
+ <div ref="pageComboEl" class="cf cf-span-3 cf-combo-field">
+ <span>{{ t('Page') }}</span>
+ <div class="be-combo">
+ <input
+ class="cf-input be-combo-input"
+ type="text"
+ :value="pageSearchInput"
+ :placeholder="selectedPageLabel || t('All pages')"
+ :disabled="!serviceName"
+ :title="t('Search page path')"
+ @focus="pageComboOpen = true"
+ @input="onPageInput"
+ />
+ <button
+ v-if="selectedPageId || pageSearchInput"
+ type="button"
+ class="be-combo-clear"
+ :title="t('Clear page')"
+ @click="clearPage"
+ >×</button>
+ <ul v-if="pageComboOpen" class="be-combo-list">
+ <li class="be-combo-item" :class="{ on: !selectedPageId }" @click="clearPage"><em>{{ t('All pages') }}</em></li>
+ <li
+ v-for="p in pageList"
+ :key="p.id"
+ class="be-combo-item"
+ :class="{ on: selectedPageId === p.id }"
+ @click="pickPage(p)"
+ >{{ p.name }}</li>
+ <li v-if="pageList.length === 0" class="be-combo-empty">{{ t('no matches') }}</li>
+ </ul>
+ </div>
+ </div>
+ <label class="cf cf-wide">
+ <span>{{ t('Time range') }}</span>
+ <template v-if="isCustomRange">
+ <div class="cf-range">
+ <input v-model="customStart" type="datetime-local" class="cf-input cf-range-num" />
+ <span class="cf-range-sep">–</span>
+ <input v-model="customEnd" type="datetime-local" class="cf-input cf-range-num" />
+ <button class="sw-btn small ghost" type="button" :title="t('Back to presets')" @click="windowMinutes = 30">×</button>
+ </div>
+ </template>
+ <select v-else v-model.number="windowMinutes" class="cf-input">
+ <option v-for="p in TIME_RANGE_PRESETS" :key="p.minutes" :value="p.minutes">{{ t(p.label) }}</option>
+ <option :value="CUSTOM_RANGE_SENTINEL">{{ t('Custom…') }}</option>
+ </select>
+ </label>
+ </div>
+ <SourceMapManager
+ v-if="showMaps"
+ :maps="sourceMaps"
+ :usage="usage"
+ :enabled="mapsEnabled"
+ :busy="mapsBusy"
+ @upload="onUpload"
+ @remove="onRemove"
+ @refresh="loadMaps"
+ />
+ <p v-if="mapsError" class="be-maps-err">{{ mapsError }}</p>
+ </section>
+
+ <section class="lg-body sw-card">
+ <div class="lg-main">
+ <!-- Category legend — one chip per category with the in-window
+ count; click toggles the stream filter. Mirrors the Logs
+ "Levels" strip. -->
+ <div v-if="logs.length > 0" class="lg-legend">
+ <span class="lg-legend-kicker">{{ t('Categories') }}</span>
+ <button
+ v-for="c in CATEGORY_ORDER"
+ :key="c"
+ type="button"
+ class="lg-legend-chip"
+ :class="{ on: selectedCat === c }"
+ @click="toggleCat(c)"
+ >
+ <span class="lvl-dot" :style="{ background: CATEGORY_COLOR[c] }" />
+ <span class="lg-legend-name">{{ c }}</span>
+ <span v-if="catFacet[c] > 0" class="lg-legend-count">{{ catFacet[c] }}</span>
+ </button>
+ <span class="lg-legend-sample">{{ t('{count} in window', { count: logs.length }) }}</span>
+ </div>
+
+ <!-- Density bar — x: time, y: count, colour: category. -->
+ <div class="lg-density-wrap" v-if="histogram.bins.length > 0" @mouseleave="hoveredBin = null">
+ <div class="lg-density">
+ <div
+ v-for="(bin, i) in histogram.bins"
+ :key="i"
+ class="lg-density-bin"
+ @mouseenter="hoveredBin = i"
+ >
+ <span
+ v-for="c in CATEGORY_ORDER"
+ :key="c"
+ class="lg-density-segment"
+ :style="{
+ background: CATEGORY_COLOR[c],
+ height: histogram.max ? (bin[c] / histogram.max * 100) + '%' : '0%',
+ }"
+ />
+ </div>
+ <div
+ v-if="hoveredBin !== null"
+ class="lg-density-tip"
+ :style="{ left: ((hoveredBin + 0.5) / 60) * 100 + '%' }"
+ >
+ <div class="lg-density-tip-time">{{ fmtBucketRange(hoveredBin, histogram.t0, histogram.t1) }}</div>
+ <div class="lg-density-tip-total">
+ {{ t('{count} logs', { count: binTotal(histogram.bins[hoveredBin]) }) }}
+ </div>
+ <div class="lg-density-tip-rows">
+ <span v-for="c in CATEGORY_ORDER" :key="c" v-show="histogram.bins[hoveredBin][c] > 0" class="lg-density-tip-row">
+ <span class="lvl-dot" :style="{ background: CATEGORY_COLOR[c] }" />
+ <span class="lg-density-tip-name">{{ c }}</span>
+ <span class="lg-density-tip-val mono">{{ histogram.bins[hoveredBin][c] }}</span>
+ </span>
+ </div>
+ </div>
+ </div>
+ <div class="lg-density-axis">
+ <span class="t-tick">{{ fmtAxisTime(histogram.t0) }}</span>
+ <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.25) }}</span>
+ <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.5) }}</span>
+ <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.75) }}</span>
+ <span class="t-tick">{{ fmtAxisTime(histogram.t1) }}</span>
+ </div>
+ </div>
+
+ <!-- States + stream -->
+ <div v-if="!serviceName" class="lg-empty">{{ t('Select an app to view its browser logs.') }}</div>
+ <div v-else-if="isFetching && logs.length === 0" class="lg-empty">{{ t('Reading data…') }}</div>
+ <div v-else-if="!reachable" class="lg-empty">{{ t('Backend unreachable.') }}<span v-if="queryError"> {{ queryError }}</span></div>
+ <div v-else-if="filteredLogs.length === 0" class="lg-empty">
+ {{ logs.length === 0 ? t('No browser logs in this window.') : t('No logs match the selected category.') }}
+ </div>
+ <div v-else class="lg-stream">
+ <template v-for="(r, idx) in filteredLogs" :key="rowKey(r, idx)">
+ <div
+ class="lg-row"
+ :class="{ 'is-open': expanded === idx }"
+ :style="{ boxShadow: `inset 3px 0 0 ${CATEGORY_COLOR[catOf(r)]}` }"
+ @click="toggleRow(idx)"
+ >
+ <span class="lg-time mono">{{ fmtTime(r.time) }}</span>
+ <span class="lg-date mono dim">{{ fmtDate(r.time) }}</span>
+ <span class="lg-lvl" :style="{ color: CATEGORY_COLOR[catOf(r)] }">{{ r.category }}</span>
+ <span class="lg-svc mono dim" :title="r.pagePath">{{ r.pagePath || '—' }}</span>
+ <span class="lg-ver mono dim" :title="r.serviceVersion">{{ r.serviceVersion || '—' }}</span>
+ <span class="lg-content mono">
+ <span v-if="loc(r)" class="lg-loc">{{ loc(r) }}</span>
+ <span class="lg-content-body">{{ r.message || t('(no message)') }}</span>
+ </span>
+ </div>
+
+ <div v-if="expanded === idx" class="lg-expand" @click.stop>
+ <div class="be-meta">
+ <span v-if="r.errorUrl" class="be-meta-item mono" :title="r.errorUrl">{{ r.errorUrl }}</span>
+ <span v-if="r.grade" class="be-meta-item">{{ t('grade') }}: {{ r.grade }}</span>
+ <span v-if="r.firstReportedError" class="be-meta-item be-first">{{ t('first occurrence') }}</span>
+ </div>
+ <div class="be-cols">
+ <div class="be-col">
+ <div class="be-col-head">{{ t('Raw stack') }}</div>
+ <pre class="be-pre">{{ r.stack || t('(no stack)') }}</pre>
+ </div>
+ <div class="be-col">
+ <div class="be-col-head">
+ <span>{{ t('Resolved') }}</span>
+ <select v-model="selectedMapId" class="f-select be-map-pick" :disabled="sourceMaps.length === 0">
+ <option value="" disabled>{{ sourceMaps.length ? t('Pick a source map…') : t('No maps loaded') }}</option>
+ <option v-for="m in sourceMaps" :key="m.id" :value="m.id">
+ {{ m.label }}{{ m.origin === 'upload' ? ' ' + t('(temp)') : '' }}
+ </option>
+ </select>
+ <button class="sw-btn small primary" type="button" :disabled="!selectedMapId || resolveBusy" @click="resolveRow(r)">{{ t('Resolve') }}</button>
+ </div>
+ <p v-if="resolveErr" class="be-maps-err">{{ resolveErr }}</p>
+ <p v-else-if="resolveBusy" class="be-dim">{{ t('Resolving…') }}</p>
+ <div v-else-if="resolved && resolved.ok" class="be-frames">
+ <div v-for="(f, fi) in resolved.frames" :key="fi" class="be-frame">
+ <div class="be-frame-line">
+ <span class="be-frame-fn">{{ f.generated.fn || t('(anonymous)') }}</span>
+ <span v-if="f.original" class="be-frame-orig">
+ {{ f.original.source }}:{{ f.original.line }}:{{ f.original.col }}
+ <span v-if="f.original.name" class="be-frame-name">→ {{ f.original.name }}</span>
+ </span>
+ <span v-else class="be-frame-unmapped">{{ t('unmapped ({line}:{col})', { line: f.generated.line, col: f.generated.col }) }}</span>
+ </div>
+ <pre v-if="f.snippet" class="be-snippet"><code v-for="(ln, li) in f.snippet.lines" :key="li" class="be-snip-line" :class="{ 'is-hit': f.original && f.snippet.startLine + li === f.original.line }"><span class="be-snip-no">{{ f.snippet.startLine + li }}</span><span class="be-snip-code">{{ ln }}</span></code></pre>
+ </div>
+ </div>
+ <p v-else class="be-dim">{{ t('Pick a source map and resolve to de-obfuscate this stack.') }}</p>
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+
+ <div class="lg-pager">
+ <span class="hint">{{ t('page {page} · showing {shown} of {total} loaded', { page, shown: filteredLogs.length, total }) }}</span>
+ <div class="lg-pager-ctrls">
+ <button class="sw-btn small" type="button" :disabled="page <= 1 || isFetching" @click="page--">{{ t('Prev') }}</button>
+ <button class="sw-btn small" type="button" :disabled="!hasMorePages || isFetching" @click="page++">{{ t('Next') }}</button>
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+</template>
+
+<style scoped>
+/* Visual vocabulary copied from the Logs tab so the two read identically
+ (LayerLogsView keeps its own scoped copy — they intentionally don't
+ share a stylesheet so neither can regress the other). */
+.lg-tab { display: flex; flex-direction: column; gap: 12px; padding: 4px 0 0; }
+
+/* Conditions card — mirrors the Logs conditions bar: a padded sw-card
+ with a kicker + Run-query head, then a 4-column grid of label-on-top
+ full-width fields. */
+/* overflow:visible so the Page combobox dropdown isn't clipped by the
+ card's rounded-corner overflow (same as the Logs conditions card). */
+.lg-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; overflow: visible; }
+.lg-toolbar-head { display: flex; align-items: center; gap: 10px; width: 100%; }
+.kicker { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--sw-accent); font-weight: 600; }
+.be-head-right { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
+.lg-conditions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 10px; }
+@media (max-width: 900px) { .lg-conditions { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+.cf { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: var(--sw-fg-3); font-weight: 500; min-width: 0; }
+.cf-wide { grid-column: span 2; }
+/* Row 1 = Version (1 col) + Page (3 cols, fills the row); Time range
+ (span 2) then flows onto row 2 — always its own second row. */
+.cf-span-3 { grid-column: span 3; }
+.cf-input {
+ height: 28px;
+ width: 100%;
+ padding: 0 8px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 11px;
+ box-sizing: border-box;
+}
+.cf-input:focus { outline: none; border-color: var(--sw-accent-line); }
+.cf-input:disabled { opacity: 0.5; cursor: not-allowed; }
+.cf-range { display: flex; align-items: center; gap: 4px; }
+.cf-range-num { flex: 1; min-width: 0; }
+.cf-range-sep { color: var(--sw-fg-3); font-size: 12px; flex: 0 0 auto; }
+/* Searchable Page (endpoint) combobox — typed keyword feeds the OAP
+ endpoint search, so pages outside the top-N list still surface. */
+.cf-combo-field .be-combo { position: relative; width: 100%; }
+.be-combo { position: relative; }
+.be-combo-input { width: 100%; padding-right: 22px; }
+.be-combo-input::placeholder { color: var(--sw-fg-2); }
+.be-combo-clear {
+ position: absolute;
+ right: 6px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 16px;
+ height: 16px;
+ line-height: 14px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: var(--sw-fg-3);
+ font-size: 13px;
+ cursor: pointer;
+}
+.be-combo-clear:hover { color: var(--sw-err); }
+.be-combo-list {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin: 4px 0 0;
+ padding: 4px;
+ max-height: 240px;
+ overflow-y: auto;
+ list-style: none;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ z-index: 50;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+}
+.be-combo-item {
+ padding: 5px 8px;
+ font-size: 11px;
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-1);
+ border-radius: 3px;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.be-combo-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.be-combo-item.on { background: var(--sw-accent-soft); color: var(--sw-accent-2); font-weight: 600; }
+.be-combo-item em { font-style: normal; }
+.be-combo-empty { padding: 6px 8px; font-size: 10.5px; color: var(--sw-fg-3); }
+.lg-legend-sample { margin-left: auto; font-size: 10.5px; color: var(--sw-fg-3); font-family: var(--sw-mono); }
+.be-maps-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 26px;
+ background: transparent;
+ border: 1px solid var(--sw-line-2);
+ border-radius: 6px;
+ color: var(--sw-fg-2);
+ font: inherit;
+ font-size: 11.5px;
+ padding: 4px 10px;
+ cursor: pointer;
+}
+.be-maps-toggle:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.be-caret { color: var(--sw-fg-3); }
+.be-maps-count {
+ font-family: var(--sw-mono);
+ font-size: 10.5px;
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-2);
+ border-radius: 8px;
+ padding: 0 6px;
+}
+.be-maps-err { margin: 0; font-size: 11.5px; color: var(--sw-err); }
+
+.lg-body { padding: 0; min-height: 540px; }
+.lg-main { display: flex; flex-direction: column; min-height: 0; }
+
+.lg-legend {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+ flex-wrap: wrap;
+}
+.lg-legend-kicker {
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--sw-fg-3);
+ margin-right: 6px;
+}
+.lg-legend-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 9px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 12px;
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 11.5px;
+ cursor: pointer;
+}
+.lg-legend-chip:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.lg-legend-chip.on { color: var(--sw-accent-2); background: var(--sw-accent-soft); border-color: var(--sw-accent-line); }
+.lg-legend-name { text-transform: uppercase; letter-spacing: 0.04em; font-size: 10.5px; }
+.lg-legend-count {
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--sw-fg-2);
+ padding: 0 4px;
+ border-left: 1px solid var(--sw-line-2);
+ margin-left: 2px;
+}
+.lg-legend-chip.on .lg-legend-count { color: var(--sw-accent-2); border-color: var(--sw-accent-line); }
+.lg-legend-tools { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; }
+.lvl-dot { width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
+
+.f-select {
+ height: 26px;
+ padding: 0 6px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ color: var(--sw-fg-0);
+ font: inherit;
+ font-size: 11px;
+}
+.f-select:focus { outline: none; border-color: var(--sw-accent-line); }
+.f-select:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.lg-density-wrap { padding: 8px 12px 4px; border-bottom: 1px solid var(--sw-line); background: var(--sw-bg-1); }
+.lg-density { display: grid; grid-template-columns: repeat(60, 1fr); align-items: end; gap: 1px; height: 60px; position: relative; }
+.lg-density-bin { display: flex; flex-direction: column-reverse; height: 100%; background: var(--sw-bg-2); border-radius: 1px; overflow: hidden; }
+.lg-density-bin:hover { outline: 1px solid var(--sw-accent-line); }
+.lg-density-segment { display: block; }
+.lg-density-tip {
+ position: absolute;
+ bottom: calc(100% + 6px);
+ transform: translateX(-50%);
+ min-width: 160px;
+ padding: 6px 9px;
+ background: var(--sw-bg-0);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45);
+ font-size: 11px;
+ color: var(--sw-fg-1);
+ pointer-events: none;
+ z-index: 5;
+}
+.lg-density-tip-time { color: var(--sw-fg-3); font-family: var(--sw-mono); font-size: 10px; margin-bottom: 2px; }
+.lg-density-tip-total { color: var(--sw-fg-0); font-weight: 700; font-size: 12px; margin-bottom: 4px; }
+.lg-density-tip-rows { display: flex; flex-direction: column; gap: 2px; }
+.lg-density-tip-row { display: inline-flex; align-items: center; gap: 6px; font-size: 10.5px; }
+.lg-density-tip-row .lvl-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 7px; }
+.lg-density-tip-name { color: var(--sw-fg-2); flex: 1; text-transform: uppercase; letter-spacing: 0.04em; }
+.lg-density-tip-val { color: var(--sw-fg-0); font-weight: 600; font-variant-numeric: tabular-nums; }
+.lg-density-axis {
+ display: flex;
+ justify-content: space-between;
+ font-family: var(--sw-mono);
+ font-size: 9.5px;
+ color: var(--sw-fg-3);
+ font-variant-numeric: tabular-nums;
+ margin-top: 4px;
+ padding: 0 2px;
+}
+.lg-density-axis .t-tick:first-child { text-align: left; }
+.lg-density-axis .t-tick:last-child { text-align: right; }
+
+.lg-empty { padding: 32px; text-align: center; color: var(--sw-fg-3); font-size: 11.5px; }
+.lg-stream { flex: 1; overflow-y: auto; font-size: 11.5px; }
+.lg-row {
+ display: grid;
+ grid-template-columns: 92px 44px 74px 150px 90px 1fr;
+ gap: 10px;
+ align-items: center;
+ padding: 4px 12px;
+ border-bottom: 1px solid var(--sw-line);
+ cursor: pointer;
+}
+.lg-row:hover { background: var(--sw-bg-2); }
+.lg-row.is-open { background: var(--sw-bg-2); }
+.lg-time { font-family: var(--sw-mono); color: var(--sw-fg-1); }
+.lg-date { font-size: 10px; }
+.lg-lvl { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; }
+.lg-svc, .lg-ver { font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.lg-content {
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ color: var(--sw-fg-1);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+.lg-loc {
+ flex: 0 0 auto;
+ font-size: 9.5px;
+ color: var(--sw-fg-3);
+ background: var(--sw-bg-3);
+ border-radius: 3px;
+ padding: 0 5px;
+ font-variant-numeric: tabular-nums;
+}
+.lg-content-body { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.lg-expand { padding: 10px 14px 14px 28px; border-bottom: 1px solid var(--sw-line); background: var(--sw-bg-2); }
+.be-meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: 10.5px; color: var(--sw-fg-3); margin-bottom: 8px; }
+.be-meta-item { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; }
+.be-first { color: var(--sw-warn); }
+.be-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.be-col { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+/* Both column heads share a min-height so "Raw stack" lines up with the
+ "Resolved" head (which carries the map picker + Resolve button) and the
+ two panes below start at the same Y. */
+.be-col-head { display: flex; align-items: center; gap: 6px; min-height: 28px; font-size: 11px; font-weight: 600; color: var(--sw-fg-2); }
+.be-map-pick { margin-left: auto; max-width: 220px; }
+.be-pre {
+ margin: 0;
+ background: var(--sw-bg-1);
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ padding: 8px;
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--sw-fg-1);
+ overflow-x: auto;
+ white-space: pre;
+ max-height: 280px;
+}
+.be-frames { display: flex; flex-direction: column; gap: 8px; }
+.be-resolved-by { margin: 0 0 2px; font-size: 11px; color: var(--sw-fg-3); }
+.be-frame { display: flex; flex-direction: column; border: 1px solid var(--sw-line); border-radius: 5px; overflow: hidden; background: var(--sw-bg-0); }
+.be-frame-line { font-family: var(--sw-mono); font-size: 11px; display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; padding: 5px 9px; background: var(--sw-bg-1); }
+.be-frame-fn { color: var(--sw-fg-0); font-weight: 600; }
+.be-frame-orig { color: var(--sw-cyan); }
+.be-frame-name { color: var(--sw-info); }
+.be-frame-unmapped { color: var(--sw-fg-3); margin-left: auto; font-style: italic; }
+/* Source snippet — a small code block with a line-number gutter; the
+ mapped line is tinted + its number accented. */
+.be-snippet {
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ line-height: 1.6;
+ border-top: 1px solid var(--sw-line);
+ overflow-x: auto;
+}
+.be-snip-line { display: flex; white-space: pre; }
+.be-snip-no {
+ flex: 0 0 auto;
+ width: 30px;
+ text-align: right;
+ padding: 0 8px;
+ color: var(--sw-fg-3);
+ background: var(--sw-bg-1);
+ border-right: 1px solid var(--sw-line);
+ user-select: none;
+}
+.be-snip-code { padding: 0 10px; color: var(--sw-fg-1); white-space: pre; }
+.be-snip-line.is-hit { background: color-mix(in srgb, var(--sw-accent) 13%, transparent); }
+.be-snip-line.is-hit .be-snip-no { color: var(--sw-accent-2); background: color-mix(in srgb, var(--sw-accent) 22%, transparent); font-weight: 700; }
+.be-snip-line.is-hit .be-snip-code { color: var(--sw-fg-0); }
+.be-dim { color: var(--sw-fg-3); font-size: 11px; padding: 4px 0; }
+
+.lg-pager { display: flex; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--sw-line); }
+.lg-pager .hint { font-size: 10.5px; color: var(--sw-fg-3); }
+.lg-pager-ctrls { margin-left: auto; display: inline-flex; gap: 6px; }
+.dim { color: var(--sw-fg-3); }
+.mono { font-family: var(--sw-mono); }
+.sw-btn {
+ height: 26px;
+ padding: 0 12px;
+ border-radius: 4px;
+ font: inherit;
+ font-size: 11px;
+ font-weight: 500;
+ border: 1px solid var(--sw-line-2);
+ background: var(--sw-bg-2);
+ color: var(--sw-fg-1);
+ cursor: pointer;
+}
+.sw-btn:hover { color: var(--sw-fg-0); border-color: var(--sw-line); }
+.sw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.sw-btn.small { height: 24px; padding: 0 10px; }
+.sw-btn.ghost { background: transparent; }
+.sw-btn.primary {
+ background: var(--sw-accent);
+ color: var(--sw-bg-0);
+ border: none;
+ padding: 0 14px;
+ font-weight: 600;
+}
+.sw-btn.primary:hover { background: var(--sw-accent-2); color: var(--sw-bg-0); }
+</style>
diff --git a/apps/ui/src/layer/browser-errors/SourceMapManager.vue b/apps/ui/src/layer/browser-errors/SourceMapManager.vue
new file mode 100644
index 0000000..e59e286
--- /dev/null
+++ b/apps/ui/src/layer/browser-errors/SourceMapManager.vue
@@ -0,0 +1,268 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Source-map cache manager for the Browser Errors tab. Presentational:
+ the parent owns the data (list + usage) and the API calls; this emits
+ upload / remove / refresh. Surfaces the "maps are temporary, in BFF
+ memory" caveat + a usage bar so operators understand eviction.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { SourceMapDescriptor, SourceMapUsage } from '@/api/client';
+import Btn from '@/components/primitives/Btn.vue';
+import Icon from '@/components/icons/Icon.vue';
+
+const { t } = useI18n({ useScope: 'global' });
+
+const props = defineProps<{
+ maps: SourceMapDescriptor[];
+ usage: SourceMapUsage | null;
+ enabled: boolean;
+ busy?: boolean;
+}>();
+const emit = defineEmits<{
+ (e: 'upload', file: File): void;
+ (e: 'remove', id: string): void;
+ (e: 'refresh'): void;
+}>();
+
+const fileInput = ref<HTMLInputElement | null>(null);
+
+function pick(): void {
+ fileInput.value?.click();
+}
+function onFile(ev: Event): void {
+ const input = ev.target as HTMLInputElement;
+ const file = input.files?.[0];
+ if (file) emit('upload', file);
+ input.value = ''; // allow re-uploading the same filename
+}
+
+function fmtBytes(n: number): string {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+function fmtAge(ms: number): string {
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
+ if (s < 60) return `${s}s ago`;
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
+ return `${Math.round(s / 86400)}d ago`;
+}
+
+const usedPct = computed(() => {
+ if (!props.usage || props.usage.maxTotalBytes <= 0) return 0;
+ return Math.min(100, Math.round((props.usage.usedBytes / props.usage.maxTotalBytes) * 100));
+});
+</script>
+
+<template>
+ <div class="smm">
+ <div class="smm-head">
+ <div class="smm-title"><Icon name="fn" /><span>{{ t('Source maps') }}</span></div>
+ <div class="smm-actions">
+ <Btn size="sm" kind="ghost" :disabled="busy" @click="emit('refresh')"><Icon name="refresh" />{{ t('Refresh') }}</Btn>
+ <Btn size="sm" kind="primary" :disabled="!enabled || busy" @click="pick"><Icon name="plus" />{{ t('Upload .map') }}</Btn>
+ <input ref="fileInput" type="file" accept=".map,application/json" hidden @change="onFile" />
+ </div>
+ </div>
+
+ <p v-if="!enabled" class="smm-note smm-note--off">
+ {{ t('Source-map de-obfuscation is disabled on this server (sourceMaps.enabled: false).') }}
+ </p>
+ <template v-else>
+ <p class="smm-note">
+ {{ t('Uploaded maps are held in the server memory only — evicted least-recently-used under memory pressure and lost on restart. For durable provisioning, mount .map files into the server\'s static source-map directory; those are reloaded automatically and can\'t be deleted here.') }}
+ </p>
+
+ <div v-if="usage" class="smm-usage">
+ <div class="smm-bar"><span class="smm-bar-fill" :style="{ width: usedPct + '%' }" /></div>
+ <span class="smm-usage-txt">
+ {{ t('{used} / {total} · {count} maps · max {perFile}/file', {
+ used: fmtBytes(usage.usedBytes),
+ total: fmtBytes(usage.maxTotalBytes),
+ count: maps.length,
+ perFile: fmtBytes(usage.maxFileBytes),
+ }) }}
+ </span>
+ </div>
+
+ <ul v-if="maps.length > 0" class="smm-list">
+ <li v-for="m in maps" :key="m.id" class="smm-item">
+ <span class="smm-name" :title="m.label">{{ m.label }}</span>
+ <span class="smm-badge" :class="m.origin === 'mount' ? 'is-mount' : 'is-upload'">
+ {{ m.origin === 'mount' ? t('Mounted') : t('Uploaded · temporary') }}
+ </span>
+ <span class="smm-meta">{{ fmtBytes(m.bytes) }}</span>
+ <span class="smm-meta">{{ fmtAge(m.addedAt) }}</span>
+ <button
+ v-if="m.origin === 'upload'"
+ class="smm-del"
+ :disabled="busy"
+ :title="t('Remove from memory')"
+ @click="emit('remove', m.id)"
+ >
+ <Icon name="more" />{{ t('Remove') }}
+ </button>
+ <span v-else class="smm-meta smm-meta--dim">{{ t('durable') }}</span>
+ </li>
+ </ul>
+ <p v-else class="smm-empty">{{ t('No source maps loaded. Upload a .map to de-obfuscate stacks.') }}</p>
+ </template>
+ </div>
+</template>
+
+<style scoped>
+.smm {
+ border: 1px solid var(--sw-line);
+ border-radius: 8px;
+ background: var(--sw-bg-1);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.smm-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.smm-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--sw-fg-1);
+}
+.smm-actions {
+ display: flex;
+ gap: 6px;
+}
+.smm-note {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--sw-fg-2);
+}
+.smm-note--off {
+ color: var(--sw-warn);
+}
+.smm-note code,
+.smm-empty code {
+ font-family: var(--sw-mono);
+ font-size: 11px;
+ color: var(--sw-fg-1);
+}
+.smm-usage {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.smm-bar {
+ flex: 1;
+ height: 6px;
+ border-radius: 3px;
+ background: var(--sw-bg-2);
+ overflow: hidden;
+}
+.smm-bar-fill {
+ display: block;
+ height: 100%;
+ background: var(--sw-cyan);
+ transition: width 0.2s ease;
+}
+.smm-usage-txt {
+ font-size: 11px;
+ color: var(--sw-fg-2);
+ white-space: nowrap;
+}
+.smm-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.smm-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 5px 8px;
+ border-radius: 4px;
+ background: var(--sw-bg-2);
+ font-size: 12px;
+}
+.smm-name {
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-1);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+}
+.smm-badge {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 10px;
+ white-space: nowrap;
+}
+.smm-badge.is-upload {
+ color: var(--sw-warn);
+ background: color-mix(in srgb, var(--sw-warn) 15%, transparent);
+}
+.smm-badge.is-mount {
+ color: var(--sw-info);
+ background: color-mix(in srgb, var(--sw-info) 15%, transparent);
+}
+.smm-meta {
+ font-size: 11px;
+ color: var(--sw-fg-2);
+ white-space: nowrap;
+}
+.smm-meta--dim {
+ opacity: 0.6;
+}
+.smm-del {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ border: none;
+ background: transparent;
+ color: var(--sw-err);
+ cursor: pointer;
+ font-size: 11px;
+ padding: 2px 4px;
+ border-radius: 4px;
+}
+.smm-del:hover {
+ background: color-mix(in srgb, var(--sw-err) 15%, transparent);
+}
+.smm-del:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.smm-empty {
+ margin: 0;
+ font-size: 12px;
+ color: var(--sw-fg-2);
+}
+</style>
diff --git a/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts b/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts
new file mode 100644
index 0000000..d128f2a
--- /dev/null
+++ b/apps/ui/src/layer/browser-errors/useLayerBrowserErrors.ts
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bffClient } from '@/api/client';
+import type { BrowserErrorCategory, BrowserErrorsResponse } from '@/api/client';
+
+export interface BrowserErrorParams {
+ service: Ref<string | null>;
+ /** OAP serviceVersionId — the BROWSER "Version" (instance) filter. */
+ serviceVersionId?: Ref<string>;
+ /** OAP pagePathId — the BROWSER "Page" (endpoint) filter. */
+ pagePathId?: Ref<string>;
+ category: Ref<BrowserErrorCategory>;
+ page: Ref<number>;
+ pageSize: Ref<number>;
+ /** Rolling window in minutes (ignored when an explicit start/end pair is
+ * supplied). Own to this triage page — the global ticker is paused here. */
+ windowMinutes: Ref<number>;
+ /** Explicit absolute range as epoch MS; when both set they win over
+ * `windowMinutes`. The BFF applies the OAP timezone offset. */
+ startMs?: Ref<number | null>;
+ endMs?: Ref<number | null>;
+}
+
+export function useLayerBrowserErrors(layerKey: Ref<string>, params: BrowserErrorParams) {
+ const q = useQuery<BrowserErrorsResponse>({
+ queryKey: [
+ 'layer-browser-errors',
+ layerKey,
+ params.service,
+ params.serviceVersionId ?? computed(() => ''),
+ params.pagePathId ?? computed(() => ''),
+ params.category,
+ params.page,
+ params.pageSize,
+ params.windowMinutes,
+ params.startMs ?? computed(() => null),
+ params.endMs ?? computed(() => null),
+ ],
+ queryFn: () =>
+ bffClient.browserErrors.list(layerKey.value, {
+ ...(params.service.value ? { service: params.service.value } : {}),
+ ...(params.serviceVersionId?.value ? { serviceVersionId: params.serviceVersionId.value } : {}),
+ ...(params.pagePathId?.value ? { pagePathId: params.pagePathId.value } : {}),
+ ...(params.category.value && params.category.value !== 'ALL'
+ ? { category: params.category.value }
+ : {}),
+ ...(params.startMs?.value && params.endMs?.value
+ ? { startMs: params.startMs.value, endMs: params.endMs.value }
+ : params.windowMinutes.value
+ ? { windowMinutes: params.windowMinutes.value }
+ : {}),
+ page: params.page.value,
+ pageSize: params.pageSize.value,
+ }),
+ enabled: computed(() => layerKey.value.length > 0),
+ staleTime: 15_000,
+ });
+ // No auto-refresh subscription: this is a triage page that owns its time
+ // range, and the topbar suspends the global ticker for it ("Paused").
+
+ return {
+ data: computed(() => q.data.value ?? null),
+ logs: computed(() => q.data.value?.logs ?? []),
+ total: computed(() => q.data.value?.total ?? 0),
+ reachable: computed(() => q.data.value?.reachable ?? true),
+ queryError: computed(() => q.data.value?.error ?? null),
+ isFetching: q.isFetching,
+ error: q.error,
+ refetch: q.refetch,
+ };
+}
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 946e4e7..b2d7912 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -82,7 +82,7 @@ function isSingleFeatureLayer(L: SidebarLayer): boolean {
if (hasInstances(L) || hasEndpoints(L)) return false;
if (hasTopology(L)) return false;
const c = L.caps;
- if (c.traces || c.logs || c.traceProfiling || c.ebpfProfiling || c.asyncProfiling || c.events) return false;
+ if (c.traces || c.logs || c.browserErrors || c.traceProfiling || c.ebpfProfiling || c.asyncProfiling || c.events) return false;
if (c.endpointDependency || c.serviceMap || c.instanceTopology || c.processTopology || c.deployment) return false;
return true;
}
@@ -579,6 +579,14 @@ watch(
>
<Icon name="log" /><span>Logs</span>
</RouterLink>
+ <RouterLink
+ v-if="L.caps.browserErrors"
+ :to="`/layer/${L.key}/browser-errors`"
+ class="sw-nav-item"
+ :class="{ 'is-active': isActive(`/layer/${L.key}/browser-errors`) }"
+ >
+ <Icon name="web" /><span>{{ t('Browser Logs') }}</span>
+ </RouterLink>
<RouterLink
v-if="L.caps.podLogs"
:to="`/layer/${L.key}/pod-logs`"
@@ -734,6 +742,14 @@ watch(
>
<Icon name="log" /><span>Logs</span>
</RouterLink>
+ <RouterLink
+ v-if="E.layer.caps.browserErrors"
+ :to="`/layer/${E.layer.key}/browser-errors`"
+ class="sw-nav-item"
+ :class="{ 'is-active': isActive(`/layer/${E.layer.key}/browser-errors`) }"
+ >
+ <Icon name="web" /><span>{{ t('Browser Logs') }}</span>
+ </RouterLink>
<RouterLink
v-if="E.layer.caps.podLogs"
:to="`/layer/${E.layer.key}/pod-logs`"
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index d949a26..c720d81 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -207,6 +207,9 @@ const TIME_RANGE_OPT_OUT = [
// operator is mid-investigation. Block the global picker + pause the
// auto-refresher whenever the operator is on a Logs tab.
/^\/layer\/[^/]+\/logs$/,
+ // Browser Logs is a triage view with its own Time range picker (same as
+ // Logs) — auto-refresh would shift the visible window mid-investigation.
+ /^\/layer\/[^/]+\/browser-errors$/,
// Pod Logs is a live tail driven by its own interval poll — the
// global ticker would double-fire and the page has no rolling window
// to refresh. Pause it while on the Pod Logs tab.
diff --git a/apps/ui/src/shell/layerFromTemplate.ts b/apps/ui/src/shell/layerFromTemplate.ts
index 3c99ad5..08e347f 100644
--- a/apps/ui/src/shell/layerFromTemplate.ts
+++ b/apps/ui/src/shell/layerFromTemplate.ts
@@ -69,6 +69,7 @@ export function componentsToCaps(
endpointDependency: !!c.endpointDependency,
traces: !!c.traces,
logs: !!c.logs,
+ browserErrors: !!c.browserErrors,
podLogs: !!c.podLogs,
traceProfiling: !!c.traceProfiling,
ebpfProfiling: !!c.ebpfProfiling,
diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts
index fea777a..98c5504 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -80,6 +80,8 @@ function layerRoute(): RouteRecordRaw {
// this path regardless of source.
{ path: 'zipkin-trace', component: () => import('@/layer/traces/LayerTracesEntry.vue') },
{ path: 'logs', component: () => import('@/layer/logs/LayerLogsView.vue') },
+ // BROWSER-layer JS error logs + source-map de-obfuscation (#6784).
+ { path: 'browser-errors', component: () => import('@/layer/browser-errors/LayerBrowserErrorsView.vue') },
// On-demand pod logs (live tail). Instance-pinned; only K8s-
// deployed layers (caps.podLogs) surface the tab in the sidebar.
{ path: 'pod-logs', component: () => import('@/layer/pod-logs/LayerPodLogsView.vue') },
diff --git a/apps/ui/src/shell/useLayers.ts b/apps/ui/src/shell/useLayers.ts
index 645fadc..98afa08 100644
--- a/apps/ui/src/shell/useLayers.ts
+++ b/apps/ui/src/shell/useLayers.ts
@@ -156,6 +156,7 @@ export function firstLayerTab(L: LayerDef | undefined): string {
if (L.caps?.endpointDependency) return 'dependency';
if (L.caps?.traces) return 'trace';
if (L.caps?.logs) return 'logs';
+ if (L.caps?.browserErrors) return 'browser-errors';
if (L.caps?.podLogs) return 'pod-logs';
if (L.caps?.traceProfiling) return 'trace-profiling';
if (L.caps?.ebpfProfiling) return 'ebpf-profiling';
diff --git a/docs/access-control/rbac.md b/docs/access-control/rbac.md
index 0727d50..79eca5e 100644
--- a/docs/access-control/rbac.md
+++ b/docs/access-control/rbac.md
@@ -23,6 +23,7 @@ Known verbs are grouped into areas:
| `alarms:read` | Alarms page, alarm widgets on overviews. |
| `traces:read` | Traces tab on any layer, trace detail page. |
| `logs:read` | Logs tab on any layer, log detail page. |
+| `browser-errors:read` | Browser Logs tab (BROWSER layer): list JS error logs, list source maps, resolve a stack. |
| `topology:read` | Topology tab, topology widgets on overviews. |
| `profile:read` | Profiling tab (results read-only). |
| `overview:read` | Public overview dashboards. |
@@ -43,6 +44,7 @@ Known verbs are grouped into areas:
| `rule:delete` | DSL Management — delete a rule. |
| `rule:debug` | DSL Management — debug a rule against sample input. |
| `live-debug:read` / `live-debug:write` | Live Debugger — observe / start sessions. |
+| `source-map:write` | Browser Logs — upload / remove source maps (held in BFF memory). |
| `profile:enable` | Create a profiling task on a layer. |
### Platform monitoring
@@ -94,7 +96,7 @@ Default definitions (used when `rbac.roles` is not overridden):
Read-only data catalog. Deliberately limited — does not include `*:read` so a viewer cannot peek at rule definitions, live-debug sessions, setup screens, or platform internals.
```
-metrics:read, alarms:read, traces:read, logs:read, topology:read, profile:read, overview:read
+metrics:read, alarms:read, traces:read, logs:read, browser-errors:read, topology:read, profile:read, overview:read
```
### `maintainer`
@@ -112,6 +114,7 @@ Configures observability. Inherits maintainer's reads + write access to dashboar
```
maintainer baseline +
overview:read, overview:write,
+source-map:write,
setup:read, setup:write,
dashboard:read, dashboard:write,
alarm-setup:read, alarm-setup:write,
diff --git a/docs/menu.yml b/docs/menu.yml
index 3787efa..c9562ba 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -65,6 +65,8 @@ catalog:
path: "/operate/inspect"
- name: "3D Infrastructure Map"
path: "/operate/infra-3d-map"
+ - name: "Browser Logs & Source Maps"
+ path: "/operate/browser-source-maps"
- name: "Access Control"
catalog:
diff --git a/docs/operate/browser-source-maps.md b/docs/operate/browser-source-maps.md
new file mode 100644
index 0000000..69c8638
--- /dev/null
+++ b/docs/operate/browser-source-maps.md
@@ -0,0 +1,140 @@
+# Browser Logs & Source Maps
+
+The **BROWSER** layer has a **Browser Logs** tab that lists the JavaScript
+errors your browser agent reports — message, category (`JS`, `PROMISE`, `VUE`,
+`AJAX`, `RESOURCE`), the page, the app version, and the minified `line:col`.
+In production those positions point into minified, bundled code, so the stack
+is unreadable. Upload the build's **source map** and Horizon resolves the stack
+back to the original file, line, column, and symbol — with a source snippet.
+
+## Viewing browser logs
+
+Open the **BROWSER** layer and pick the **Browser Logs** tab. Choose an app in
+the selector, then narrow with the **Version**, **Page**, and **Time range**
+conditions, and the **Category** legend above the stream (click a category to
+filter; the counts stay visible). Each row is one reported error, newest first.
+
+The **Time range** is owned by this page — the global topbar picker is paused
+while you're here, so auto-refresh won't shift the window mid-investigation.
+Pick a rolling preset (default **Last 30 min**) or a **Custom** absolute
+start/end.
+
+Click a row to expand it. The left side shows the **raw stack** as the browser
+reported it; the right side is where you resolve it. An error's first
+occurrence is flagged in that expanded detail.
+
+## Resolving a minified stack
+
+1. Make a source map available (upload it, or provision it statically — below).
+2. Expand the error row.
+3. Pick the map from the **source map** dropdown.
+4. Click **Resolve**.
+
+Horizon parses the stack, maps each frame through the chosen map, and shows the
+original `file:line:column`, the original symbol name, and a few lines of
+original source around each frame. The map you used is named above the result,
+because **which map matches which build is your call** — see [Matching maps to
+builds](#matching-maps-to-builds).
+
+Source maps cover anything that compiles to browser JavaScript and ships a
+standard source map: JavaScript, TypeScript, JSX/TSX, Vue, Svelte, and the
+output of webpack, Vite/Rollup, esbuild, Babel, `tsc`, and minifiers. Code with
+no source map, inline/`eval`-ed code, and WebAssembly can't be resolved.
+
+Which **categories** carry a resolvable stack: **`JS`**, **`PROMISE`**, and
+**`VUE`** are real JavaScript errors whose stack points into your bundle —
+these resolve. **`AJAX`** and **`RESOURCE`** are network/load failures (a failed
+request or asset), so their "stack" is an HTTP status or URL, not code — there
+is nothing for a source map to translate. (Only the `JS` category reports a
+top-level `line:col`; for the others the position lives inside the stack string,
+which the resolver parses.)
+
+## Two ways to provide maps
+
+### Upload (temporary)
+
+The **Upload .map** button on the tab loads a map into the server's memory for
+immediate use. These uploads are **temporary by design**:
+
+- They live in the server's memory only — there is no backend storage.
+- They are evicted **least-recently-used** once the configured memory budget is
+ reached, and they are **lost when the server restarts**.
+- In a multi-instance deployment a map you upload is only visible on the
+ instance that received it.
+
+The tab shows current memory usage against the budget and warns that uploaded
+maps are temporary. Use uploads for ad-hoc triage.
+
+### Static mount (durable)
+
+For durable provisioning, place `.map` files in the server's **source-map
+directory** and they're indexed at boot. Each file is validated as a Source Map
+v3 at index time, so only real maps appear in the picker (others are skipped
+with a log warning), and the directory is bounded by `maxFileCount` — beyond it,
+extra files are skipped. Mounted maps survive restarts, reload on demand, and
+can't be deleted from the UI. In the container image the directory is
+`/app/sourcemaps` (set by `HORIZON_SOURCEMAPS_DIR`); bind-mount or copy your
+build's maps there. See [Container Image](../setup/container-image.md).
+
+## Matching maps to builds
+
+A source map only resolves correctly against the **exact build** it was
+generated from. The browser agent reports an app **version** but no build
+fingerprint, so Horizon does not auto-pick a map — **you choose** which map to
+apply, and the resolved view names it so you can confirm. Applying a map from a
+different build yields confidently wrong line numbers, so keep your maps
+labelled by version and pick the one that matches the error's app version.
+
+> Source maps' `sourcesContent` embeds your original source code. Treat the
+> maps you upload or mount as sensitive, and provision them only on servers you
+> trust.
+
+## Configuration
+
+Budgets and the static directory are set in the `sourceMaps` block of
+`horizon.yaml`:
+
+```yaml
+sourceMaps:
+ enabled: true # turn the upload/resolve controls on or off
+ maxFileBytes: 67108864 # 64 MiB — reject any single .map larger than this
+ maxTotalBytes: 536870912 # 512 MiB — resident-upload budget (LRU-evicted past it)
+ maxFileCount: 128 # per-set map cap — the uploaded set and the mounted set are each bounded by this
+ bootMountDir: /app/sourcemaps # static .map directory scanned at boot ("" disables it)
+```
+
+`maxTotalBytes` bounds the **resident uploaded maps** (raw `.map` bytes held in
+memory). A single upload larger than it is rejected; past it, the
+least-recently-used uploaded maps are evicted. On top of that, a few
+recently-resolved maps are kept **parsed** (bounded to a small count) — parsed
+structures run larger than the raw file, so size the container with headroom
+(≈2× the budget is comfortable). Mounted maps are disk-backed and don't count
+against this budget. Size `maxTotalBytes` against the memory available to the
+server — maps with `sourcesContent` are commonly tens of MiB each. When
+`enabled` is `false`, the tab still lists errors but the map controls are hidden.
+
+> **Restart-only knobs.** `enabled`, `maxTotalBytes`, and `maxFileCount` apply
+> live on a config reload — lowering a budget trims the **uploaded** set on the
+> next upload / resolve / list (least-recently-used first). Maps already loaded
+> from the mount are fixed by the boot scan: lowering `maxFileCount` afterwards
+> won't shrink the mounted set (restart to re-scan the mount against the lower
+> count). Two changes need a restart: `bootMountDir` (the directory is scanned
+> once at startup, so changes — and newly-dropped `.map` files — take effect on
+> restart), and **raising** `maxFileBytes` (the upload size limit is fixed at
+> startup; lowering it applies live).
+
+**Hot reload.** `enabled` and the budgets take effect immediately on a
+`horizon.yaml` change — with one exception: the upload size limit is fixed when
+the server starts, so *raising* `maxFileBytes` to accept a larger upload needs a
+restart (lowering it, and toggling `enabled`, apply live).
+
+## Permissions
+
+| Action | Permission |
+|---|---|
+| View logs, list maps, resolve a stack | `browser-errors:read` |
+| Upload or remove a source map | `source-map:write` |
+
+The default **viewer**, **maintainer**, and **operator** roles can read and
+resolve; **operator** (and **admin**) can upload and remove. See
+[Roles and Permissions](../access-control/rbac.md).
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index f9c2ff0..3043df5 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -32,6 +32,7 @@ The full commit SHA is the canonical, immutable identifier. Moving tags are conv
| `/app/horizon.yaml` | n/a | n/a | Where the BFF expects the **active** config. **Not present in the image** — provide via mount or `COPY` (see below). |
| `/app/bundled_templates/` | **horizon** | **yes** | Layer + overview JSON templates. Owned by `horizon` because the admin **Layer-Templates** and **Overview-Templates** editors write into per-key files here. |
| `/data/` | **horizon** | **yes** | Declared `VOLUME`. Default destination for the audit log, setup state, alarm state, and wire debug log. Mount a PVC / named volume / host bind here for durable storage. |
+| `/app/sourcemaps/` | **horizon** | (read) | Static source maps for the **Browser Logs** tab. Bind-mount or copy `.map` files here and they're loaded at boot — durable across restarts. Optional; runtime uploads work without it. See [Browser Logs & Source Maps](../operate/browser-source-maps.md). |
The runtime stage runs as the non-root user `horizon`. Two locations are owned by `horizon` so writes work without operator intervention: `bundled_templates/` (so the admin editors save) and `/data/` (so state files land somewhere durable).
@@ -47,6 +48,7 @@ The runtime stage runs as the non-root user `horizon`. Two locations are owned b
| `HORIZON_SETUP_FILE` | `/data/horizon-setup.json` | Default for `setup.file`. |
| `HORIZON_ALARMS_FILE` | `/data/horizon-alarms.json` | Default for `alarms.file`. |
| `HORIZON_WIRE_LOG_FILE` | `/data/horizon-wire.jsonl` | Default for `debugLog.file`. |
+| `HORIZON_SOURCEMAPS_DIR` | `/app/sourcemaps` | Default for `sourceMaps.bootMountDir` — the directory scanned at boot for statically-provisioned `.map` files. See [Browser Logs & Source Maps](../operate/browser-source-maps.md). |
The four `HORIZON_*_FILE` env vars seed the **defaults** the config schema uses when `horizon.yaml` doesn't supply a value. An explicit value in `horizon.yaml` always wins. The intent: an operator who runs the published image with only a minimal `horizon.yaml` (no `audit/setup/alarms/debugLog` blocks) gets state files routed to `/data/` automatically, no manual path overrides needed.
diff --git a/docs/setup/horizon-yaml.md b/docs/setup/horizon-yaml.md
index 7aa916f..99a208c 100644
--- a/docs/setup/horizon-yaml.md
+++ b/docs/setup/horizon-yaml.md
@@ -15,6 +15,7 @@ This page is the top-level map. Each subsection has its own detail page:
| `setup` / `alarms` | State file paths. | [files](files.md) |
| `debugLog` | Wire-level request/response log for troubleshooting. | [debugLog](debug-log.md) |
| `query` | Per-request query limits (the layer-landing service cap). | [below](#query-limits) |
+| `sourceMaps` | In-memory source-map budgets + static mount for the Browser Logs tab. | [Browser Logs & Source Maps](../operate/browser-source-maps.md) |
| `layers` | Layers to hide from the sidebar. | [below](#excluded-layers) |
## Top-level shape
@@ -46,6 +47,7 @@ audit: { file? }
setup: { file? }
alarms: { file? }
debugLog: { enabled?, file?, maxBodyChars?, redactAuthHeaders? }
+sourceMaps: { enabled?, maxFileBytes?, maxTotalBytes?, maxFileCount?, bootMountDir? }
layers: { excluded?: [{ key, reason? }] }
```
@@ -90,11 +92,14 @@ The config is re-read on file change and the new values take effect without a re
- RBAC roles and policy (re-evaluated on next route call).
- OAP URLs and credentials (used on next outbound call).
- Session TTL (new sessions use the new TTL; existing sessions keep their original).
+- `sourceMaps.enabled`, `sourceMaps.maxTotalBytes`, `sourceMaps.maxFileCount` — applied on the next source-map upload / resolve / list. Lowering a budget trims the in-memory **uploaded** set then (least-recently-used first). It does **not** shrink maps already loaded from the static mount — see below.
-Two changes require a process restart:
+These changes require a process restart:
- `server.host`, `server.port` — the listener already bound.
- Capability probes — the OAP schema introspection cache is per-process.
+- `sourceMaps.bootMountDir` — the static source-map directory is scanned once at startup, so a new directory (and newly-dropped `.map` files) needs a restart. The count of maps loaded from that mount is fixed by the startup scan as well: lowering `sourceMaps.maxFileCount` afterwards trims only the in-memory uploaded set, never the already-mounted maps — restart to re-scan a mount against a lower count.
+- **Raising** `sourceMaps.maxFileBytes` — the multipart upload size limit is fixed at startup; lowering it applies live.
## Query limits
diff --git a/horizon.example.yaml b/horizon.example.yaml
index 05984c8..1c9d8a3 100644
--- a/horizon.example.yaml
+++ b/horizon.example.yaml
@@ -144,6 +144,7 @@ rbac:
- alarms:read
- traces:read
- logs:read
+ - browser-errors:read
- topology:read
- profile:read
- overview:read
@@ -154,6 +155,7 @@ rbac:
- alarms:read
- traces:read
- logs:read
+ - browser-errors:read
- topology:read
- profile:read
- overview:read
@@ -170,6 +172,8 @@ rbac:
- alarms:read
- traces:read
- logs:read
+ - browser-errors:read
+ - source-map:write
- topology:read
- profile:read
- cluster:read
@@ -225,3 +229,21 @@ debugLog:
enabled: false
maxBodyChars: 8192
redactAuthHeaders: true
+
+# JS source maps for the BROWSER-layer "Browser Errors" tab (#6784).
+# Maps de-obfuscate minified error stacks back to original source. They
+# are held in the BFF process memory ONLY — there is no OAP-side storage —
+# so this cache is per-instance and intentionally temporary: uploaded maps
+# are lost on restart and the least-recently-used are evicted once the
+# budget is hit. For durable provisioning, drop .map files into the static
+# `bootMountDir` (Docker/k8s mount) — those are reloaded on demand and
+# survive restarts.
+sourceMaps:
+ enabled: true
+ maxFileBytes: 67108864 # 64 MiB — reject any single .map larger
+ maxTotalBytes: 536870912 # 512 MiB — total in-memory budget (LRU-evicted)
+ maxFileCount: 128 # cap on number of hosted maps
+ # Directory scanned at boot for statically-provisioned .map files. The
+ # published image sets HORIZON_SOURCEMAPS_DIR=/app/sourcemaps; leave empty
+ # to disable the static mount.
+ bootMountDir: ${HORIZON_SOURCEMAPS_DIR:}
diff --git a/packages/api-client/src/browser-errors.ts b/packages/api-client/src/browser-errors.ts
new file mode 100644
index 0000000..658e843
--- /dev/null
+++ b/packages/api-client/src/browser-errors.ts
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Wire types for the BROWSER-layer "Browser Errors" tab (#6784).
+ *
+ * Two halves:
+ * - The error log itself — a verbatim mirror of OAP's
+ * `queryBrowserErrorLogs(BrowserErrorLogQueryCondition)` shape.
+ * - The source-map cache the BFF holds in memory to de-obfuscate those
+ * errors' stacks. Maps are NOT stored in OAP; the descriptors below
+ * describe a per-BFF-instance, intentionally-temporary cache.
+ */
+
+/** OAP `ErrorCategory` enum, verbatim. `ALL` means "no filter". */
+export type BrowserErrorCategory =
+ | 'ALL'
+ | 'AJAX'
+ | 'RESOURCE'
+ | 'VUE'
+ | 'PROMISE'
+ | 'JS'
+ | 'UNKNOWN';
+
+/** One `BrowserErrorLog` row. Fields mirror the GraphQL type 1:1. */
+export interface BrowserErrorRow {
+ service: string;
+ serviceVersion: string;
+ /** Unix millis (OAP `time` Long). */
+ time: number;
+ pagePath: string;
+ category: BrowserErrorCategory;
+ grade: string | null;
+ message: string | null;
+ /** Top-level error position. OAP only populates these for the `JS`
+ * category (the `window.onerror` path); `PROMISE` / `VUE` / `AJAX`
+ * report 0 and carry their real frames inside `stack`. */
+ line: number | null;
+ col: number | null;
+ stack: string | null;
+ errorUrl: string | null;
+ firstReportedError: boolean;
+}
+
+export interface BrowserErrorsQueryRequest {
+ /** OAP service id (`<base64>.<digits>`). */
+ serviceId?: string;
+ /** Service NAME — resolved to an id server-side (mirror of the logs route). */
+ service?: string;
+ serviceVersionId?: string;
+ pagePathId?: string;
+ /** Omitted or `ALL` ⇒ no category filter. */
+ category?: BrowserErrorCategory;
+ page?: number;
+ pageSize?: number;
+ /** Rolling window in minutes, ending at "now". Ignored when an explicit
+ * `startMs` / `endMs` pair is supplied. */
+ windowMinutes?: number;
+ /** Absolute range as epoch milliseconds (TZ-unambiguous). The BFF renders
+ * these into OAP-server-local time using the OAP offset — send ms, not
+ * pre-formatted local strings, so the browser↔OAP timezone delta is
+ * applied correctly. */
+ startMs?: number;
+ endMs?: number;
+}
+
+export interface BrowserErrorsResponse {
+ generatedAt: number;
+ query: BrowserErrorsQueryRequest;
+ /** OAP no longer returns a cross-page total for this query; the BFF
+ * reports the returned row count, same as the logs feed. */
+ total: number;
+ logs: BrowserErrorRow[];
+ reachable: boolean;
+ error?: string;
+}
+
+/** Where a hosted map came from. `upload` = held in memory only (lost on
+ * restart / evicted under budget). `mount` = backed by a file in the
+ * static `bootMountDir` (reloaded on demand, survives restarts). */
+export type SourceMapOrigin = 'upload' | 'mount';
+
+export interface SourceMapDescriptor {
+ id: string;
+ /** Display label — the uploaded filename, or the basename for mounts. */
+ label: string;
+ origin: SourceMapOrigin;
+ /** Raw `.map` byte size (the unit budgets are accounted in). */
+ bytes: number;
+ /** Unix millis the map was added (upload time / boot-scan time). */
+ addedAt: number;
+ /** Unix millis of the last resolve that used this map (drives LRU). */
+ lastUsedAt: number;
+}
+
+/** Snapshot of the in-memory cache + its budget, for the manager UI's
+ * usage bar and the "temporary" warning. */
+export interface SourceMapUsage {
+ usedBytes: number;
+ maxTotalBytes: number;
+ maxFileBytes: number;
+ maxFileCount: number;
+}
+
+export interface SourceMapListResponse {
+ /** False when `sourceMaps.enabled` is off — the UI hides the controls. */
+ enabled: boolean;
+ maps: SourceMapDescriptor[];
+ usage: SourceMapUsage;
+}
+
+export interface SourceMapUploadResponse {
+ ok: boolean;
+ map?: SourceMapDescriptor;
+ /** `disabled` | `too_large` | `invalid_map` | `no_file`. */
+ error?: string;
+}
+
+export interface ResolveRequest {
+ /** Raw browser `error.stack` string — the primary source of frames. */
+ stack?: string;
+ /** Top-level position fallback (used when `stack` yields no frames,
+ * e.g. a `JS`-category error with only `line`/`col`). */
+ line?: number;
+ col?: number;
+ errorUrl?: string;
+ category?: BrowserErrorCategory;
+ /** Id of the map the operator chose to resolve against. */
+ sourceMapId: string;
+}
+
+/** One resolved stack frame: the minified position as parsed from the
+ * stack, plus the original position the map points at (null if the
+ * frame falls outside the map). */
+export interface ResolvedFrame {
+ generated: { file: string | null; line: number | null; col: number | null; fn: string | null };
+ original:
+ | { source: string | null; line: number | null; col: number | null; name: string | null }
+ | null;
+ /** A few lines of original source around the position, when the map
+ * carries `sourcesContent`. */
+ snippet?: { startLine: number; lines: string[] } | null;
+}
+
+export interface ResolveResponse {
+ ok: boolean;
+ /** Echo the chosen map so the UI can show "resolved against <label>"
+ * — version-matching is the operator's call, so naming it matters. */
+ sourceMapId: string;
+ sourceMapLabel: string | null;
+ frames: ResolvedFrame[];
+ /** `disabled` | `unknown_map` | `no_input`. */
+ error?: string;
+}
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 45920e3..c6a73a4 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -119,6 +119,20 @@ export type {
LogsResponse,
LogFacetsResponse,
} from './logs.js';
+export type {
+ BrowserErrorCategory,
+ BrowserErrorRow,
+ BrowserErrorsQueryRequest,
+ BrowserErrorsResponse,
+ SourceMapOrigin,
+ SourceMapDescriptor,
+ SourceMapUsage,
+ SourceMapListResponse,
+ SourceMapUploadResponse,
+ ResolveRequest,
+ ResolvedFrame,
+ ResolveResponse,
+} from './browser-errors.js';
export type { OapInfo, OapCapabilities, OapBackend } from './oap-info.js';
export type {
RecordsTTL,
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 22f7492..9096ee7 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -59,6 +59,9 @@ export interface LayerCaps {
dashboards?: boolean;
traces?: boolean;
logs?: boolean;
+ /** Per-layer "Browser Errors" tab — BROWSER-layer JS error logs with
+ * source-map de-obfuscation of the minified stack (#6784). */
+ browserErrors?: boolean;
/** Trace-driven thread profiling (the original SkyWalking profile). */
traceProfiling?: boolean;
/** Kernel-level CPU / off-CPU profiling sourced from eBPF agents. */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 678f285..bb4385c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,9 +26,15 @@ importers:
'@fastify/cookie':
specifier: ^11.0.1
version: 11.0.2
+ '@fastify/multipart':
+ specifier: ^10.0.0
+ version: 10.0.0
'@fastify/static':
specifier: ^9.1.1
version: 9.1.3
+ '@jridgewell/trace-mapping':
+ specifier: ^0.3.31
+ version: 0.3.31
'@skywalking-horizon-ui/api-client':
specifier: workspace:*
version: link:../../packages/api-client
@@ -50,6 +56,9 @@ importers:
pino-pretty:
specifier: ^11.3.0
version: 11.3.0
+ stacktrace-parser:
+ specifier: ^0.1.11
+ version: 0.1.11
yaml:
specifier: ^2.6.0
version: 2.9.0
@@ -751,9 +760,15 @@ packages:
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
+ '@fastify/busboy@3.2.0':
+ resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
+
'@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
+ '@fastify/deepmerge@3.2.1':
+ resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
+
'@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@@ -766,6 +781,9 @@ packages:
'@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
+ '@fastify/multipart@10.0.0':
+ resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==}
+
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -3344,6 +3362,10 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ stacktrace-parser@0.1.11:
+ resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
+ engines: {node: '>=6'}
+
stats-gl@2.4.2:
resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==}
peerDependencies:
@@ -3531,6 +3553,10 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
+ type-fest@0.7.1:
+ resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
+ engines: {node: '>=8'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -4246,11 +4272,15 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.2
+ '@fastify/busboy@3.2.0': {}
+
'@fastify/cookie@11.0.2':
dependencies:
cookie: 1.1.1
fastify-plugin: 5.1.0
+ '@fastify/deepmerge@3.2.1': {}
+
'@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.0.3':
@@ -4263,6 +4293,14 @@ snapshots:
dependencies:
dequal: 2.0.3
+ '@fastify/multipart@10.0.0':
+ dependencies:
+ '@fastify/busboy': 3.2.0
+ '@fastify/deepmerge': 3.2.1
+ '@fastify/error': 4.2.0
+ fastify-plugin: 5.1.0
+ secure-json-parse: 4.1.0
+
'@fastify/proxy-addr@5.1.0':
dependencies:
'@fastify/forwarded': 3.0.1
@@ -7113,6 +7151,10 @@ snapshots:
stackback@0.0.2: {}
+ stacktrace-parser@0.1.11:
+ dependencies:
+ type-fest: 0.7.1
+
stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0):
dependencies:
'@types/three': 0.184.1
@@ -7296,6 +7338,8 @@ snapshots:
type-fest@0.20.2: {}
+ type-fest@0.7.1: {}
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4