UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

284 lines 12.2 kB
/** * Slack hard limits we format within. A `text` field in a section is capped at * 3000 chars; we keep the stack snippet WELL under that (the code fence + other * fields share the block) and additionally cap by frame count so the message * stays scannable rather than a wall of frames. */ const STACK_CHAR_LIMIT = 2_800; const STACK_FRAME_LIMIT = 10; /** * Slack caps a `section` block's `fields` array at 10 items — an 11th makes Slack * reject the WHOLE message with `400 invalid_blocks`. A fully-enriched exception * (instance + observed + error + route + UA + referer + duration + user + client * IP + location + occurrences) exceeds that, so we spread the fields across as * many section blocks as needed rather than overflowing one. */ const MAX_SECTION_FIELDS = 10; /** * Char budget for the AI diagnosis section. The diagnoser is already bounded by * `maxOutputTokens`, but a long report still has to share Slack's per-section * 3000-char cap with its `*Probable cause (AI):*` label, so we hard-clip here. */ const DIAGNOSIS_CHAR_LIMIT = 2_800; /** * Severity → leading emoji. Telescope has no severity model yet, so we derive a * coarse one from the rule kind: a brand-new error family or an exception spike is * the loudest signal an operator can get, dropped entries means we're losing data * (serious but not user-facing), and a slow-request spike is a warning. */ function severityEmoji(rule) { if (rule.type === 'new-exception' || rule.type === 'every-exception' || rule.type === 'exception-rate') { return ':rotating_light:'; } if (rule.type === 'dropped-entries') return ':warning:'; return ':snail:'; } /** Short, human rule label for the header (the `type` is machine-y on its own). */ function ruleLabel(rule) { switch (rule.type) { case 'new-exception': return 'New exception family'; case 'every-exception': return 'Exception'; case 'exception-rate': return 'Exception rate'; case 'slow-request-rate': return 'Slow request rate'; case 'dropped-entries': return 'Dropped entries'; case 'metric-threshold': return `Metric threshold (${rule.metric})`; } } /** A Slack `mrkdwn` field pairing a bold label with a value. */ function field(label, value) { return { type: 'mrkdwn', text: `*${label}:*\n${value}` }; } /** * Clip a raw stack to Slack's budget: keep at most {@link STACK_FRAME_LIMIT} * lines, then hard-cap the joined string at {@link STACK_CHAR_LIMIT} chars (a * single huge frame can still blow the budget). Returns `null` for an absent * stack so the caller can omit the block entirely rather than render an empty * code fence. */ function clipStack(stack) { if (stack === null || stack.trim() === '') return null; const frames = stack.split('\n').slice(0, STACK_FRAME_LIMIT).join('\n'); if (frames.length <= STACK_CHAR_LIMIT) return frames; return `${frames.slice(0, STACK_CHAR_LIMIT)}…`; } /** Regional-indicator flag emoji for an ISO 3166-1 alpha-2 code, or `''`. */ function flagEmoji(countryCode) { if (countryCode === undefined || !/^[A-Za-z]{2}$/.test(countryCode)) return ''; const cc = countryCode.toUpperCase(); const base = 0x1f1e6 - 65; // 'A' → 🇦 return String.fromCodePoint(base + cc.charCodeAt(0), base + cc.charCodeAt(1)); } /** * Render a coarse geo location as `🇺🇸 City, Region, Country`, skipping absent or * duplicate parts (a city equal to its region isn't repeated). Returns `null` when * there's nothing to show, so the caller omits the field entirely. */ function formatGeo(geo) { if (!geo) return null; const parts = []; if (geo.city) parts.push(geo.city); if (geo.region && geo.region !== geo.city) parts.push(geo.region); if (geo.country && geo.country !== geo.region) parts.push(geo.country); if (parts.length === 0) return null; const flag = flagEmoji(geo.countryCode); return flag ? `${flag} ${parts.join(', ')}` : parts.join(', '); } /** * Clip an auxiliary code block (React component stack / serialized `extra`) to * Slack's per-section budget. Returns `null` for absent/empty input so the caller * omits the block rather than rendering an empty fence. */ function clipBlock(text) { if (text === null || text.trim() === '') return null; if (text.length <= STACK_CHAR_LIMIT) return text; return `${text.slice(0, STACK_CHAR_LIMIT)}…`; } /** * Best-effort pretty JSON for the `extra` bag. The Recorder already redacted + * depth-bounded it, but a circular ref could still slip through a host-built * object, so we fall back to a placeholder rather than throwing into formatting. */ function safeJson(value) { try { return JSON.stringify(value, null, 2); } catch { return '[unserializable]'; } } /** Trim + length-cap the AI diagnosis; `null`/empty passes through as `null`. */ function clipDiagnosis(diagnosis) { if (diagnosis === undefined || diagnosis.trim() === '') return null; const trimmed = diagnosis.trim(); if (trimmed.length <= DIAGNOSIS_CHAR_LIMIT) return trimmed; return `${trimmed.slice(0, DIAGNOSIS_CHAR_LIMIT)}…`; } /** * Build the deep link to the offending exception entry in the host's external * dashboard. Returns `null` when no `dashboardUrl` is configured or there is no * entry id to link to (rate rules carry no single id). * * The hash route mirrors the SPA's entry-DETAIL route `#/entries/view/:id` * (see `packages/ui/src/app/App.tsx`). It deliberately does NOT use the older * `#/entries/:type/:id` shape: `#/entries/<type>` matches the type-scoped LIST * route `#/entries/:type`, so a recipient clicking the button landed on an empty * filtered list rather than the entry detail. The detail view is type-agnostic * (the same `EntryPage` renders both `exception` and `client_exception` by id), * so a single `view/:id` link works for both. */ function dashboardLink(payload) { const { dashboardUrl, exception } = payload; if (dashboardUrl === undefined || exception === undefined) return null; const base = dashboardUrl.replace(/\/+$/, ''); return `${base}#/entries/view/${exception.entryId}`; } /** * Render an {@link AlertPayload} into a Slack Block Kit message. Structure: * - a `header` with the severity emoji + rule label; * - a `section` whose fields carry the app/rule context (instance, value vs * threshold, window, and — for `new-exception` — route/method/status/user and * the occurrence count); * - a `section` with a fenced code block of the truncated stack (only when an * exception stack is present); * - an `actions` block with a single "Open in Telescope" button (only when a * `dashboardUrl` + entry id are available to build the deep link). * * Everything degrades gracefully: a rate rule (no `exception` context) simply * renders the header + context fields and skips the stack/button. */ export function formatSlackMessage(payload, options) { const emoji = severityEmoji(payload.rule); const label = ruleLabel(payload.rule); // Badge a brand-new error family distinctly from a recurrence so on-call can // triage urgency at a glance. Only exceptions carry this; rate rules don't. const badge = payload.exception === undefined ? '' : payload.exception.isNew ? ' · 🆕 New' : ' · 🔁 Recurring'; const headerText = `${emoji} ${label}${badge}`; const contextFields = [ field('Instance', payload.instanceId), field('Observed', `${payload.value} (threshold ${payload.threshold})`), ]; const exception = payload.exception; if (exception !== undefined) { contextFields.push(field('Error', `${exception.class}: ${exception.message}`)); if (exception.route !== null) { // For a client_exception, `route` is the page URL (no method/status). const label = exception.client ? 'URL' : 'Route'; const method = exception.method ?? ''; const status = exception.statusCode === null ? '' : ` → ${exception.statusCode}`; contextFields.push(field(label, `${method} ${exception.route}${status}`.trim())); } if (exception.userAgent !== null) { contextFields.push(field('User agent', exception.userAgent)); } if (exception.referer !== null) { contextFields.push(field('Referer', exception.referer)); } if (exception.durationMs !== null) { contextFields.push(field('Duration', `${exception.durationMs} ms`)); } if (exception.user !== null) { contextFields.push(field('User', exception.user)); } if (exception.clientIp !== null) { contextFields.push(field('Client IP', exception.clientIp)); } const geoText = formatGeo(exception.geo); if (geoText !== null) { contextFields.push(field('Location', geoText)); } contextFields.push(field('Occurrences', `${exception.occurrences} in window`)); } else { // Rate rules carry the matched rule's window; surface it for context. const window = 'window' in payload.rule ? payload.rule.window : null; if (window !== null) contextFields.push(field('Window', window)); } const blocks = [ { type: 'header', text: { type: 'plain_text', text: headerText, emoji: true } }, ]; // Spread the context fields across section blocks of at most MAX_SECTION_FIELDS — // one overflowing section makes Slack reject the entire message. for (let i = 0; i < contextFields.length; i += MAX_SECTION_FIELDS) { blocks.push({ type: 'section', fields: contextFields.slice(i, i + MAX_SECTION_FIELDS) }); } const stack = exception ? clipStack(exception.stack) : null; if (stack !== null) { blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `\`\`\`${stack}\`\`\`` } }); } // React component stack (client_exception from an error boundary), when present. const componentStack = exception ? clipBlock(exception.componentStack) : null; if (componentStack !== null) { blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*Component stack:*\n\`\`\`${componentStack}\`\`\`` }, }); } // Host-defined free-form `extra` bag (client_exception), serialized as JSON. const extra = exception && exception.extra !== null && Object.keys(exception.extra).length > 0 ? clipBlock(safeJson(exception.extra)) : null; if (extra !== null) { blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*Extra:*\n\`\`\`${extra}\`\`\`` }, }); } // AI probable-cause note (auto-mode), when one finished within the alert grace. // Slack's mrkdwn isn't full markdown, but headings/bullets degrade readably; we // just length-cap so the diagnosis can't blow Slack's 3000-char section budget. const diagnosis = clipDiagnosis(payload.diagnosis); if (diagnosis !== null) { blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*Probable cause (AI):*\n${diagnosis}` }, }); } const link = dashboardLink(payload); if (link !== null) { blocks.push({ type: 'actions', elements: [ { type: 'button', text: { type: 'plain_text', text: 'Open in Telescope', emoji: true }, url: link, }, ], }); } return { // Fallback text mirrors the header so notifications/badges are meaningful. text: headerText, blocks, ...(options?.username !== undefined ? { username: options.username } : {}), ...(options?.iconEmoji !== undefined ? { icon_emoji: options.iconEmoji } : {}), }; } //# sourceMappingURL=slack-format.js.map