Last active
July 23, 2026 12:59
-
-
Save lopes/041a25c7792303eb15ab600251f5c11b to your computer and use it in GitHub Desktop.
Setup and full context: https://lopes.id/log/log-health-monitoring
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // log-health.gs — Apps Script for Log-Source Health Monitoring | |
| // | |
| // Delivers Slack notifications for the log_health pipeline: | |
| // - checkAnomalies (every 10 min): posts new SPIKE/DIP/FULL_STOP/NEW_SOURCE | |
| // alerts from the anomalies table. | |
| // - dailyDigest (daily): source-by-source status, 24h alert tally, | |
| // unclassified NEW_SOURCE callout, and candidate-only watchlist. | |
| // - weeklyPipelineHealth (Mondays): reads pipeline_health_status; posts if | |
| // any log_health scheduled job didn't run in the last 25h. | |
| // - weeklyUnspecifiedGrowth (Mondays): reads unspecified_growth_status; | |
| // posts if UNSPECIFIED_LOG_TYPE volume grew >20% WoW. | |
| // | |
| // Setup and full context: https://lopes.id/log/log-health-monitoring | |
| // | |
| // Before using, replace: | |
| // - PROJECT_ID → the GCP project that hosts the log_health dataset | |
| // - SLACK_WEBHOOK → your Slack incoming-webhook URL | |
| // - `chronicle-google` in embedded SQL → project holding ingestion_metrics | |
| // - `chronicle-self` in embedded SQL → project holding the log_health dataset | |
| const PROJECT_ID = "chronicle-self"; | |
| const SLACK_WEBHOOK = "https://hooks.slack.com/services/T00000000/B00000000/xxxxxxxxxxxxxxxxxxxxxxxx"; | |
| // Attachment colors — one per alert/status type. | |
| const C_FULL_STOP = "#E01E5A"; // crimson | |
| const C_SPIKE = "#E8A838"; // amber | |
| const C_DIP = C_SPIKE; // shared with SPIKE — direction is carried by the emoji, not color | |
| const C_NEW_SOURCE = "#36C5F0"; // sky blue | |
| const C_DEAD = "#BF2600"; // dark red | |
| const C_ALERTING = "#E01E5A"; | |
| const C_APPROACHING = "#E8A838"; | |
| const C_NEUTRAL = "#DDDDDD"; // light grey | |
| const C_WARNING = "#E8A838"; | |
| // postSlack — posts a structured attachment message to the configured webhook. | |
| // fallback: plain-text summary shown in notification toasts and email digests. | |
| // attachments: array of { color, blocks } objects. | |
| function postSlack(fallback, attachments) { | |
| UrlFetchApp.fetch(SLACK_WEBHOOK, { | |
| method: "post", | |
| contentType: "application/json", | |
| payload: JSON.stringify({ text: fallback, attachments: attachments }), | |
| }); | |
| } | |
| // makeAttachment — color ribbon + optional bold title, body, and small footer. | |
| // isCode (default true): wraps body in triple-backticks for monospace tables. | |
| // Pass isCode=false for prose bodies that already contain mrkdwn formatting. | |
| function makeAttachment(color, title, body, footer, isCode) { | |
| const blocks = []; | |
| if (title) { | |
| blocks.push({ | |
| type: "section", | |
| text: { type: "mrkdwn", text: "*" + title + "*" }, | |
| }); | |
| } | |
| if (body) { | |
| const text = isCode !== false ? "```\n" + body + "\n```" : body; | |
| blocks.push({ type: "section", text: { type: "mrkdwn", text: text } }); | |
| } | |
| if (footer) { | |
| blocks.push({ | |
| type: "context", | |
| elements: [{ type: "mrkdwn", text: footer }], | |
| }); | |
| } | |
| return { color: color, blocks: blocks }; | |
| } | |
| // [§7 — checkAnomalies] Posts un-sent anomalies to Slack, then marks them sent. | |
| // Reads only normalized columns (actual_value/expected_value/delta_value/ | |
| // severity_pct/value_unit + cluster summary) — all math is computed in BQ | |
| // by Step B/C/E so this function is a pure formatter. No source_config | |
| // join: FULL_STOP rows carry their own max_silence_hours via expected_value. | |
| // NEW_SOURCE rows re-fire every 24h until the source is classified — each | |
| // re-fire is a separate row with sent_to_slack=FALSE. | |
| function checkAnomalies() { | |
| const sql = `SELECT detected_at, source_key, anomaly_type, hour_bucket, | |
| actual_value, expected_value, delta_value, severity_pct, value_unit, | |
| breach_first_at, breach_last_at, breach_hits, | |
| breach_overflow_evts, breach_overflow_pct, | |
| log_count | |
| FROM \`chronicle-self.log_health.anomalies\` | |
| WHERE sent_to_slack = FALSE | |
| ORDER BY detected_at`; | |
| const rows = | |
| BigQuery.Jobs.query({ query: sql, useLegacySql: false }, PROJECT_ID).rows || | |
| []; | |
| if (!rows.length) return; | |
| const padR = (s, w) => String(s).padEnd(w); | |
| const padL = (s, w) => String(s).padStart(w); | |
| // fmtFull — number with thousands separators; '—' for null/NaN. | |
| // fmtShort — short-scale (k/M/G) for OVER and OVERFLOW where magnitude matters more than precision. | |
| const fmtFull = (n) => { | |
| const v = parseFloat(n); | |
| return Number.isFinite(v) ? Math.round(v).toLocaleString("en-US") : "—"; | |
| }; | |
| const fmtShort = (n) => { | |
| const v = parseFloat(n); | |
| if (!Number.isFinite(v)) return "—"; | |
| const abs = Math.abs(v); | |
| if (abs >= 1e9) return (v / 1e9).toFixed(1) + "G"; | |
| if (abs >= 1e6) return (v / 1e6).toFixed(1) + "M"; | |
| if (abs >= 1e3) return Math.round(v / 1e3) + "k"; | |
| return Math.round(v).toString(); | |
| }; | |
| // withUnit appends 'h' only when the row's value_unit signals hours | |
| // (FULL_STOP). SPIKE/DIP carry 'evt' and render bare numbers. | |
| const withUnit = (s, u) => (s === "—" || u !== "h" ? s : s + "h"); | |
| // BigQuery returns TIMESTAMP as seconds-since-epoch strings; JS Date needs ms. | |
| // fmtHour — HH:MM UTC. Used for SPIKE/DIP window endpoints. | |
| const fmtHour = (ts) => | |
| ts | |
| ? Utilities.formatDate(new Date(parseFloat(ts) * 1000), "UTC", "HH:mm") | |
| : "—"; | |
| // Compact window: "HH:MM→HH:MM" or single "HH:MM" when first=last (magnitude-only promotions). | |
| const fmtWindow = (first, last) => { | |
| if (!first || !last) return "—"; | |
| const f = fmtHour(first), | |
| l = fmtHour(last); | |
| return f === l ? f : f + "→" + l; | |
| }; | |
| // Appends the breach-hit count to the window, e.g. "14:00→22:00 (4×)". | |
| // Omitted when hits<=1 — a single-hit (magnitude-only) promotion needs no count. | |
| const fmtWindowWithHits = (first, last, hits) => { | |
| const w = fmtWindow(first, last); | |
| const h = parseInt(hits, 10); | |
| return Number.isFinite(h) && h > 1 ? w + " (" + h + "×)" : w; | |
| }; | |
| // fmtSignedMag — the one shared "±magnitude (percent%)" cell renderer, used by | |
| // FULL_STOP's OVER, and SPIKE/DIP's PEAK and OVERFLOW. `sign` is always | |
| // caller-supplied — magnitude/pct arrive pre-ABS'd from BigQuery, so the | |
| // renderer can never infer direction on its own. | |
| // `floor` (FULL_STOP only): the row is a lower bound (silence aged out of | |
| // Step C's lookback window, value_unit='h+') — prefix "≥" and drop the ± | |
| // sign, since a floored overage is always non-negative and "≥" carries it. | |
| const fmtSignedMag = (sign, magnitude, pct, unit, floor) => { | |
| const m = parseFloat(magnitude), | |
| p = parseFloat(pct); | |
| if (!Number.isFinite(m) || !Number.isFinite(p)) return "—"; | |
| const pre = floor ? "≥" : ""; | |
| const sgn = floor ? "" : sign; | |
| return ( | |
| pre + | |
| sgn + | |
| fmtShort(m) + | |
| (unit === "h" ? "h" : "") + | |
| " (" + | |
| pre + | |
| Math.round(p) + | |
| "%)" | |
| ); | |
| }; | |
| // truncSrc — fit source key into exactly w chars; truncate with ellipsis if longer. | |
| const truncSrc = (s, w) => | |
| s.length <= w ? s.padEnd(w) : s.slice(0, w - 1) + "…"; | |
| // Layout A (FULL_STOP): SOURCE + SILENT + LIMIT + OVER = ~64 chars | |
| // SILENT = hours since last event, LIMIT = max_silence_hours, | |
| // OVER = excess hours as "+magnitude (pct%)" via fmtSignedMag. | |
| const FS_SRC_W = 32, | |
| FS_NUM_W = 8, | |
| FS_OVER_W = 13; | |
| // Layout B (SPIKE/DIP): TIME + SOURCE + PEAK + OVERFLOW | |
| // TIME = HH:MM→HH:MM UTC breach window, with the hit count folded in as | |
| // " (N×)" when N>1 (fmtWindowWithHits) — the window IS the span of | |
| // those N hits, so the count belongs there rather than its own column. | |
| // PEAK = worst-hour excess as "±magnitude (%)" (magnitude from delta_value, | |
| // % from severity_pct — both scoped to the peak hour in the cluster). | |
| // OVERFLOW = cluster-total excess as "±magnitude (weighted %)" summed across | |
| // all breach hours — same fmtSignedMag renderer as PEAK, so both are | |
| // always signed consistently. | |
| // Order matches narrative reading: window → worst → total. | |
| const SD_TIME_W = 17, | |
| SD_SRC_W = 25, | |
| SD_PEAK_W = 14, | |
| SD_OVR_W = 14; | |
| const byType = { FULL_STOP: [], NEW_SOURCE: [], SPIKE: [], DIP: [] }; | |
| rows.forEach((r) => { | |
| const v = r.f.map((c) => c.v); | |
| byType[v[2]].push({ | |
| ts: v[0], | |
| src: v[1], | |
| type: v[2], | |
| hour_bucket: v[3], | |
| actual: v[4], | |
| expected: v[5], | |
| delta: v[6], | |
| severity: v[7], | |
| unit: v[8], | |
| breach_first: v[9], | |
| breach_last: v[10], | |
| breach_hits: v[11], | |
| breach_evts: v[12], | |
| breach_pct: v[13], | |
| legacy_cnt: v[14], | |
| }); | |
| }); | |
| // Layout A — FULL_STOP: SOURCE + SILENT + LIMIT + OVER | |
| const renderFullStopHeader = | |
| padR("SOURCE", FS_SRC_W) + | |
| " " + | |
| padL("SILENT", FS_NUM_W) + | |
| " " + | |
| padL("LIMIT", FS_NUM_W) + | |
| " " + | |
| padL("OVER", FS_OVER_W) + | |
| "\n"; | |
| // value_unit='h+' ⇒ SILENT/OVER are floors (feed silent past the 336h | |
| // lookback; exact duration unmeasurable) — prefix SILENT with "≥". | |
| const renderFullStopRow = (a) => { | |
| const floor = a.unit === "h+"; | |
| return ( | |
| truncSrc(a.src, FS_SRC_W) + | |
| " " + | |
| padL((floor ? "≥" : "") + withUnit(fmtFull(a.actual), "h"), FS_NUM_W) + | |
| " " + | |
| padL(withUnit(fmtFull(a.expected), "h"), FS_NUM_W) + | |
| " " + | |
| padL(fmtSignedMag("+", a.delta, a.severity, "h", floor), FS_OVER_W) + | |
| "\n" | |
| ); | |
| }; | |
| // Layout B — SPIKE/DIP: TIME + SOURCE + PEAK + OVERFLOW | |
| const renderSpikeDipHeader = | |
| padR("TIME", SD_TIME_W) + | |
| " " + | |
| padR("SOURCE", SD_SRC_W) + | |
| " " + | |
| padL("PEAK", SD_PEAK_W) + | |
| " " + | |
| padL("OVERFLOW", SD_OVR_W) + | |
| "\n"; | |
| const renderSpikeDipRow = (a) => { | |
| const sign = a.type === "DIP" ? "-" : "+"; | |
| return ( | |
| padR( | |
| fmtWindowWithHits(a.breach_first, a.breach_last, a.breach_hits), | |
| SD_TIME_W, | |
| ) + | |
| " " + | |
| truncSrc(a.src, SD_SRC_W) + | |
| " " + | |
| padL(fmtSignedMag(sign, a.delta, a.severity), SD_PEAK_W) + | |
| " " + | |
| padL(fmtSignedMag(sign, a.breach_evts, a.breach_pct), SD_OVR_W) + | |
| "\n" | |
| ); | |
| }; | |
| // buildTableBody — sorts by severity desc, returns raw table text (no backtick wrap). | |
| const buildTableBody = (bucket, header, rowFn) => { | |
| bucket.sort((a, b) => parseFloat(b.severity) - parseFloat(a.severity)); | |
| let s = header; | |
| bucket.forEach((a) => { | |
| s += rowFn(a); | |
| }); | |
| return s.trimEnd(); | |
| }; | |
| const nowUtc = Utilities.formatDate(new Date(), "UTC", "yyyy-MM-dd HH:mm"); | |
| const attachments = []; | |
| // FULL_STOP first (most severe — feed went dark). | |
| if (byType.FULL_STOP.length) { | |
| attachments.push( | |
| makeAttachment( | |
| C_FULL_STOP, | |
| ":red_circle: FULL STOP (" + byType.FULL_STOP.length + ")", | |
| buildTableBody( | |
| byType.FULL_STOP, | |
| renderFullStopHeader, | |
| renderFullStopRow, | |
| ), | |
| "SILENT/LIMIT in hours; OVER = hours over limit (% of limit); ≥ = silent past the 336h lookback, floor only · UTC", | |
| ), | |
| ); | |
| } | |
| if (byType.SPIKE.length) { | |
| attachments.push( | |
| makeAttachment( | |
| C_SPIKE, | |
| ":arrow_up: SPIKE (" + byType.SPIKE.length + ")", | |
| buildTableBody(byType.SPIKE, renderSpikeDipHeader, renderSpikeDipRow), | |
| ":clock3: All times UTC", | |
| ), | |
| ); | |
| } | |
| if (byType.DIP.length) { | |
| attachments.push( | |
| makeAttachment( | |
| C_DIP, | |
| ":arrow_down: DIP (" + byType.DIP.length + ")", | |
| buildTableBody(byType.DIP, renderSpikeDipHeader, renderSpikeDipRow), | |
| ":clock3: All times UTC", | |
| ), | |
| ); | |
| } | |
| // NEW_SOURCE stays distinct — no baseline exists, so the canonical | |
| // SILENT/LIMIT/OVER/% columns don't apply. actual_value carries | |
| // the 24h event count (populated by Step D); legacy_cnt is a fallback | |
| // for pre-migration rows that still wrote to log_count only. | |
| if (byType.NEW_SOURCE.length) { | |
| byType.NEW_SOURCE.sort( | |
| (a, b) => | |
| parseFloat(b.actual || b.legacy_cnt) - | |
| parseFloat(a.actual || a.legacy_cnt), | |
| ); | |
| const NS_SRC_W = 38, | |
| NS_NUM_W = 12, | |
| NS_DATE_W = 16; | |
| const fmtDate = (ts) => | |
| ts | |
| ? Utilities.formatDate( | |
| new Date(parseFloat(ts) * 1000), | |
| "UTC", | |
| "yyyy-MM-dd HH:mm", | |
| ) | |
| : "—"; | |
| let nsBody = | |
| padR("SOURCE", NS_SRC_W) + | |
| " " + | |
| padL("24H EVENTS", NS_NUM_W) + | |
| " " + | |
| padL("FIRST SEEN", NS_DATE_W) + | |
| "\n"; | |
| byType.NEW_SOURCE.forEach((a) => { | |
| nsBody += | |
| padR(a.src, NS_SRC_W) + | |
| " " + | |
| padL(fmtFull(a.actual != null ? a.actual : a.legacy_cnt), NS_NUM_W) + | |
| " " + | |
| padL(fmtDate(a.hour_bucket), NS_DATE_W) + | |
| "\n"; | |
| }); | |
| attachments.push( | |
| makeAttachment( | |
| C_NEW_SOURCE, | |
| ":large_blue_circle: NEW SOURCE (" + byType.NEW_SOURCE.length + ")", | |
| nsBody.trimEnd(), | |
| null, | |
| ), | |
| ); | |
| } | |
| postSlack( | |
| ":rotating_light: `Log-Source Health Alerts — " + nowUtc + " UTC`", | |
| attachments, | |
| ); | |
| BigQuery.Jobs.query( | |
| { | |
| query: | |
| "UPDATE `chronicle-self.log_health.anomalies` SET sent_to_slack = TRUE WHERE sent_to_slack = FALSE", | |
| useLegacySql: false, | |
| }, | |
| PROJECT_ID, | |
| ); | |
| } | |
| // [§7 — dailyDigest] Tabulated status of every source, grouped by status, plus 24h alert | |
| // tally, an Unclassified-NEW_SOURCE callout, and a candidate-only watchlist. | |
| // Code-block tables (Slack monospace), sorted by severity within each section. | |
| function dailyDigest() { | |
| // Compute hours_over and pct_of_threshold in SQL so Apps Script is a pure | |
| // formatter; same math source-of-truth as Step C's FULL_STOP severity. | |
| const inventorySql = ` | |
| WITH latest_per_source AS ( | |
| SELECT log_type AS source_key, MAX(start_time) AS latest_event | |
| FROM \`chronicle-google.datalake.ingestion_metrics\` | |
| WHERE start_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 336 HOUR) | |
| AND event_count > 0 | |
| GROUP BY log_type | |
| ), | |
| computed AS ( | |
| SELECT c.source_key, | |
| c.detection_method, | |
| c.max_silence_hours, | |
| CAST(TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), l.latest_event, HOUR) AS INT64) AS hours_silent | |
| FROM \`chronicle-self.log_health.source_config\` c | |
| LEFT JOIN latest_per_source l ON l.source_key = c.source_key | |
| WHERE c.enabled | |
| ) | |
| SELECT source_key, | |
| detection_method, | |
| max_silence_hours, | |
| hours_silent, | |
| CAST(hours_silent - max_silence_hours AS INT64) AS hours_over, | |
| CAST(ROUND(SAFE_DIVIDE(hours_silent, max_silence_hours) * 100) AS INT64) AS pct_of_threshold, | |
| CASE | |
| WHEN hours_silent IS NULL THEN 'DEAD' | |
| WHEN hours_silent > max_silence_hours THEN 'ALERTING' | |
| WHEN hours_silent > max_silence_hours * 0.75 THEN 'APPROACHING' | |
| ELSE 'HEALTHY' | |
| END AS status | |
| FROM computed | |
| ORDER BY source_key | |
| `; | |
| const alertSql = ` | |
| SELECT anomaly_type, COUNT(*) AS n | |
| FROM \`chronicle-self.log_health.anomalies\` | |
| WHERE detected_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR) | |
| GROUP BY anomaly_type | |
| `; | |
| // Candidate-only sources — SPIKE/DIP detections that did NOT escalate to alerts | |
| // (failed Step E's persistence ≥4/24h AND magnitude ≥200% bars). Surface them as | |
| // capacity-planning input: "these are misbehaving but not enough to page anyone". | |
| // HAVING hits >= 2 drops single-hit transient bursts that aren't worth even reading. | |
| const candidateSql = ` | |
| SELECT source_key, anomaly_type, COUNT(*) AS hits, | |
| ROUND(MAX(severity_pct), 0) AS worst_pct | |
| FROM \`chronicle-self.log_health.anomaly_candidates\` | |
| WHERE detected_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR) | |
| AND promoted = FALSE | |
| GROUP BY source_key, anomaly_type | |
| HAVING hits >= 2 | |
| ORDER BY hits DESC, worst_pct DESC | |
| LIMIT 30 | |
| `; | |
| // Unclassified sources — log_types that fired NEW_SOURCE in the last 25h and | |
| // haven't been added to source_config or excluded_log_types yet. | |
| // The 25h window mirrors Step D's 24h dedup + 1h buffer: if a source stopped | |
| // sending events, Step D stops inserting NEW_SOURCE rows (HAVING events_24h > 0), | |
| // so last_alerted_at falls outside the window and it drops off this list. | |
| // Sources that were deprecated (like PAN_CORTEX_XDR_EVENTS) self-clear within | |
| // 25h of their last event — no manual cleanup needed. See §9.10 (a) for the runbook. | |
| const unclassifiedSql = ` | |
| WITH new_source_history AS ( | |
| SELECT source_key, | |
| MIN(detected_at) AS first_alerted_at, | |
| MAX(detected_at) AS last_alerted_at, | |
| COUNT(*) AS times_alerted | |
| FROM \`chronicle-self.log_health.anomalies\` | |
| WHERE anomaly_type = 'NEW_SOURCE' | |
| GROUP BY source_key | |
| ) | |
| SELECT h.source_key, | |
| h.times_alerted, | |
| CAST(TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), h.first_alerted_at, DAY) AS INT64) AS days_pending | |
| FROM new_source_history h | |
| WHERE h.source_key NOT IN (SELECT source_key FROM \`chronicle-self.log_health.source_config\`) | |
| AND h.source_key NOT IN (SELECT log_type FROM \`chronicle-self.log_health.excluded_log_types\`) | |
| AND h.last_alerted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 25 HOUR) | |
| ORDER BY days_pending DESC, h.source_key | |
| `; | |
| // Run all four queries. | |
| const invRows = | |
| BigQuery.Jobs.query( | |
| { query: inventorySql, useLegacySql: false }, | |
| PROJECT_ID, | |
| ).rows || []; | |
| const alertRows = | |
| BigQuery.Jobs.query({ query: alertSql, useLegacySql: false }, PROJECT_ID) | |
| .rows || []; | |
| const candRows = | |
| BigQuery.Jobs.query( | |
| { query: candidateSql, useLegacySql: false }, | |
| PROJECT_ID, | |
| ).rows || []; | |
| const unclRows = | |
| BigQuery.Jobs.query( | |
| { query: unclassifiedSql, useLegacySql: false }, | |
| PROJECT_ID, | |
| ).rows || []; | |
| // Bucket sources by status. hours_over and pct_of_threshold come | |
| // pre-computed from inventorySql so Apps Script does no math. | |
| const groups = { DEAD: [], ALERTING: [], APPROACHING: [], HEALTHY: [] }; | |
| invRows.forEach((r) => { | |
| const [src, method, max_h, silent_h, over_h, pct_th, status] = r.f.map( | |
| (c) => c.v, | |
| ); | |
| groups[status].push({ | |
| src, | |
| method, | |
| max_h: parseInt(max_h), | |
| silent_h: parseInt(silent_h), | |
| over_h: parseInt(over_h), | |
| pct_th: parseInt(pct_th), | |
| }); | |
| }); | |
| // Roll up the 24h alert counts. | |
| const c24 = { SPIKE: 0, DIP: 0, FULL_STOP: 0, NEW_SOURCE: 0 }; | |
| alertRows.forEach((r) => { | |
| const [type, n] = r.f.map((c) => c.v); | |
| c24[type] = parseInt(n); | |
| }); | |
| // Formatting helpers — same approach as checkAnomalies. | |
| const padR = (s, w) => String(s).padEnd(w); | |
| const padL = (s, w) => String(s).padStart(w); | |
| const SRC_W = 38, | |
| NUM_W = 7, | |
| PCT_W = 5; | |
| const today = Utilities.formatDate( | |
| new Date(), | |
| "America/Sao_Paulo", | |
| "yyyy-MM-dd", | |
| ); | |
| const attachments = []; | |
| // DEAD — no severity dimension; sort by source name. | |
| if (groups.DEAD.length) { | |
| groups.DEAD.sort((a, b) => a.src.localeCompare(b.src)); | |
| let body = padR("SOURCE", SRC_W) + " " + padL("METHOD", 10) + "\n"; | |
| groups.DEAD.forEach((s) => { | |
| body += padR(s.src, SRC_W) + " " + padL(s.method, 10) + "\n"; | |
| }); | |
| attachments.push( | |
| makeAttachment( | |
| C_DEAD, | |
| ":skull: DEAD (" + groups.DEAD.length + ") — no data in 14+ days", | |
| body.trimEnd(), | |
| null, | |
| ), | |
| ); | |
| } | |
| // ALERTING — sort by overage descending. | |
| if (groups.ALERTING.length) { | |
| groups.ALERTING.sort((a, b) => b.over_h - a.over_h); | |
| let body = | |
| padR("SOURCE", SRC_W) + | |
| " " + | |
| padL("SILENT", NUM_W) + | |
| " " + | |
| padL("LIMIT", NUM_W) + | |
| " " + | |
| padL("OVER", NUM_W) + | |
| "\n"; | |
| groups.ALERTING.forEach((s) => { | |
| body += | |
| padR(s.src, SRC_W) + | |
| " " + | |
| padL(s.silent_h + "h", NUM_W) + | |
| " " + | |
| padL(s.max_h + "h", NUM_W) + | |
| " " + | |
| padL("+" + s.over_h + "h", NUM_W) + | |
| "\n"; | |
| }); | |
| attachments.push( | |
| makeAttachment( | |
| C_ALERTING, | |
| ":red_circle: ALERTING (" + | |
| groups.ALERTING.length + | |
| ") — silent past max_silence_hours", | |
| body.trimEnd(), | |
| null, | |
| ), | |
| ); | |
| } | |
| // APPROACHING — sort by % of threshold descending. | |
| if (groups.APPROACHING.length) { | |
| groups.APPROACHING.sort((a, b) => b.pct_th - a.pct_th); | |
| let body = | |
| padR("SOURCE", SRC_W) + | |
| " " + | |
| padL("SILENT", NUM_W) + | |
| " " + | |
| padL("LIMIT", NUM_W) + | |
| " " + | |
| padL("%", PCT_W) + | |
| "\n"; | |
| groups.APPROACHING.forEach((s) => { | |
| body += | |
| padR(s.src, SRC_W) + | |
| " " + | |
| padL(s.silent_h + "h", NUM_W) + | |
| " " + | |
| padL(s.max_h + "h", NUM_W) + | |
| " " + | |
| padL(s.pct_th + "%", PCT_W) + | |
| "\n"; | |
| }); | |
| attachments.push( | |
| makeAttachment( | |
| C_APPROACHING, | |
| ":large_yellow_circle: APPROACHING (" + | |
| groups.APPROACHING.length + | |
| ") — silent past 75% of threshold", | |
| body.trimEnd(), | |
| null, | |
| ), | |
| ); | |
| } | |
| // HEALTHY summary + 24h tally — prose, no code block. | |
| const summaryText = | |
| ":large_green_circle: *HEALTHY*: " + | |
| groups.HEALTHY.length + | |
| " sources\n" + | |
| ":bell: *Last 24h alerts*: " + | |
| c24.SPIKE + | |
| " SPIKE · " + | |
| c24.DIP + | |
| " DIP · " + | |
| c24.FULL_STOP + | |
| " FULL_STOP · " + | |
| c24.NEW_SOURCE + | |
| " NEW_SOURCE"; | |
| attachments.push(makeAttachment(C_NEUTRAL, null, summaryText, null, false)); | |
| // Unclassified NEW_SOURCEs — pending classification (§9.10 a). | |
| if (unclRows.length) { | |
| let body = | |
| padR("SOURCE", SRC_W) + | |
| " " + | |
| padL("DAYS PENDING", 13) + | |
| " " + | |
| padL("PINGS", 6) + | |
| "\n"; | |
| unclRows.forEach((r) => { | |
| const [src, times, days] = r.f.map((c) => c.v); | |
| body += | |
| padR(src, SRC_W) + | |
| " " + | |
| padL(days + "d", 13) + | |
| " " + | |
| padL(times, 6) + | |
| "\n"; | |
| }); | |
| attachments.push( | |
| makeAttachment( | |
| C_APPROACHING, | |
| ":question: Unclassified NEW_SOURCEs (" + | |
| unclRows.length + | |
| ") — add to source_config or excluded_log_types", | |
| body.trimEnd(), | |
| null, | |
| ), | |
| ); | |
| } | |
| // Candidate-only — sources that produced SPIKE/DIP candidates without escalating. | |
| // Read as: "model thinks these are anomalous but they don't meet persistence/magnitude | |
| // bars for paging. Treat as capacity-planning input." | |
| if (candRows.length) { | |
| let body = | |
| padR("SOURCE", SRC_W) + | |
| " " + | |
| padL("TYPE", 6) + | |
| " " + | |
| padL("HITS", 5) + | |
| " " + | |
| padL("WORST %", 8) + | |
| "\n"; | |
| candRows.forEach((r) => { | |
| const [src, type, hits, worst] = r.f.map((c) => c.v); | |
| body += | |
| padR(src, SRC_W) + | |
| " " + | |
| padL(type, 6) + | |
| " " + | |
| padL(hits, 5) + | |
| " " + | |
| padL(Math.round(parseFloat(worst)) + "%", 8) + | |
| "\n"; | |
| }); | |
| attachments.push( | |
| makeAttachment( | |
| C_NEUTRAL, | |
| ":eye: Candidate-only last 24h (" + | |
| candRows.length + | |
| ") — fired but did not escalate", | |
| body.trimEnd(), | |
| null, | |
| ), | |
| ); | |
| } | |
| postSlack( | |
| ":bar_chart: `Log-Source Health Daily Digest — " + today + "`", | |
| attachments, | |
| ); | |
| } | |
| // [§6.3 / §7 — weeklyPipelineHealth] Polls pipeline_health_status (overwritten weekly | |
| // by §6.3's scheduled query). Empty table = healthy week. Any rows = a log_health | |
| // job didn't run in the last 25h or finished non-DONE; posts a Slack alert. | |
| function weeklyPipelineHealth() { | |
| const sql = `SELECT statement_type, last_state, last_run | |
| FROM \`chronicle-self.log_health.pipeline_health_status\` | |
| ORDER BY statement_type`; | |
| const rows = | |
| BigQuery.Jobs.query({ query: sql, useLegacySql: false }, PROJECT_ID).rows || | |
| []; | |
| if (!rows.length) return; | |
| const padR = (s, w) => String(s).padEnd(w); | |
| const padL = (s, w) => String(s).padStart(w); | |
| const fmtWhen = (ts) => | |
| ts | |
| ? Utilities.formatDate( | |
| new Date(parseFloat(ts) * 1000), | |
| "UTC", | |
| "yyyy-MM-dd HH:mm", | |
| ) | |
| : "—"; | |
| const nowUtc = Utilities.formatDate( | |
| new Date(), | |
| "UTC", | |
| "yyyy-MM-dd HH:mm", | |
| ); | |
| let body = | |
| padR("STATEMENT_TYPE", 22) + | |
| " " + | |
| padR("LAST STATE", 12) + | |
| " " + | |
| padL("LAST RUN (UTC)", 18) + | |
| "\n"; | |
| rows.forEach((r) => { | |
| const [stmt, last_state, last_run] = r.f.map((c) => c.v); | |
| body += | |
| padR(stmt || "—", 22) + | |
| " " + | |
| padR(last_state || "—", 12) + | |
| " " + | |
| padL(fmtWhen(last_run), 18) + | |
| "\n"; | |
| }); | |
| postSlack(":warning: `Log-Source Health — Pipeline issue(s) detected`", [ | |
| makeAttachment( | |
| C_WARNING, | |
| ":warning: Pipeline Issues — last 7d (" + rows.length + ")", | |
| body.trimEnd(), | |
| nowUtc + " UTC", | |
| ), | |
| ]); | |
| } | |
| // [§6.4 / §7 — weeklyUnspecifiedGrowth] Polls unspecified_growth_status (overwritten | |
| // weekly by §6.4's scheduled query). Empty table = stable; any row = WoW growth in | |
| // UNSPECIFIED_LOG_TYPE volume exceeded 20% (signal of an integration dropping events | |
| // into the Chronicle catch-all bucket). | |
| function weeklyUnspecifiedGrowth() { | |
| const sql = `SELECT current_week, prior_week, growth_pct | |
| FROM \`chronicle-self.log_health.unspecified_growth_status\``; | |
| const rows = | |
| BigQuery.Jobs.query({ query: sql, useLegacySql: false }, PROJECT_ID).rows || | |
| []; | |
| if (!rows.length) return; | |
| const [current, prior, growth] = rows[0].f.map((c) => c.v); | |
| const fmt = (n) => Math.round(parseFloat(n)).toLocaleString("en-US"); | |
| const nowBrt = Utilities.formatDate( | |
| new Date(), | |
| "America/Sao_Paulo", | |
| "yyyy-MM-dd HH:mm", | |
| ); | |
| const bodyText = | |
| "Current week: *" + | |
| fmt(current) + | |
| "* events · Prior week: " + | |
| fmt(prior) + | |
| " events\n" + | |
| "Growth: *" + | |
| growth + | |
| "%* (threshold: 20%)\n" + | |
| "_Investigate which integrations are dropping events into the Chronicle catch-all bucket._"; | |
| postSlack(":warning: `Log-Source Health — Unspecified-Log-Type Growth`", [ | |
| makeAttachment( | |
| C_WARNING, | |
| ":chart_with_upwards_trend: UNSPECIFIED_LOG_TYPE — WoW Growth", | |
| bodyText, | |
| nowBrt + " BRT", | |
| false, | |
| ), | |
| ]); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment