No description
Find a file
Adam Moussa 4870784fbd Add self-hosted Grafana stack: EC2, ALB, dashboards-as-code (Phase 5)
The one non-serverless piece — Grafana OSS on a t4g.small (AL2023, ARM64) in the
imported seahaven-vpc, fronted by an internet-facing ALB locked by SG to the
office CIDRs (no Client VPN exists, so "VPN-only" = office-IP restriction, the
syslog-server pattern). Instance in private subnets, reachable only from the ALB
SG, administered via SSM Session Manager (no SSH/key pair).

grafana_stack.py: ALB (HTTPS, *.seahaven.com cert, open=False so the SG office
rules aren't undone by an auto 0.0.0.0/0), instance role (Athena query + Glue
read + S3 analytics/athena-results, no static keys), Route53 grafana.seahaven.com
alias, gp3 root volume RETAINed, daily DLM snapshot of the tagged instance, and a
BucketDeployment that uploads grafana/ to the S3 config prefix.

grafana_userdata.sh: install Grafana OSS, pin the Athena datasource plugin, write
grafana.ini (root_url grafana.seahaven.com, kiosk embedding), sync provisioning +
dashboards from S3 on boot, and a systemd timer re-syncs every 15 min so repo
edits land without an instance rebuild.

Dashboard (grafana-author agent, grafana/dashboards/apm-work-orders.json, uid
apm-wo so the Slack 📊 button resolves): 7 panels — category distribution,
escalation summary, action/routine, escalations-by-site, trend time-series over
dt (the new capability), filterable WO table (5 template vars, escalation row
coloring, CSV export, no APM links), and the mismatch panel. Datasource uid
"athena" pinned in the provisioning yaml.

Tests: tests/test_grafana_synth.py — ALB admits only the office CIDRs on 443
(caught and fixed a default 0.0.0.0/0 listener rule), instance only-from-ALB,
no static keys, scoped instance role + SSM, gp3+retained root volume, daily DLM
backup, grafana.seahaven.com alias. 57/57 tests pass; full cdk synth green.
2026-05-28 18:05:20 -04:00
.claude/agents Scaffold apm-wo-analysis repository 2026-05-28 16:13:13 -04:00
.github Scaffold apm-wo-analysis repository 2026-05-28 16:13:13 -04:00
cdk Add self-hosted Grafana stack: EC2, ALB, dashboards-as-code (Phase 5) 2026-05-28 18:05:20 -04:00
docs Scaffold apm-wo-analysis repository 2026-05-28 16:13:13 -04:00
grafana Add self-hosted Grafana stack: EC2, ALB, dashboards-as-code (Phase 5) 2026-05-28 18:05:20 -04:00
lambdas Add Slack post + interactions Lambdas with drill-down modals (Phase 4) 2026-05-28 17:48:51 -04:00
scripts Add drop-folder ingestion and scoped uploader IAM user 2026-05-28 16:32:56 -04:00
slack Add Slack post + interactions Lambdas with drill-down modals (Phase 4) 2026-05-28 17:48:51 -04:00
tests Add self-hosted Grafana stack: EC2, ALB, dashboards-as-code (Phase 5) 2026-05-28 18:05:20 -04:00
.gitignore Scaffold apm-wo-analysis repository 2026-05-28 16:13:13 -04:00
CLAUDE.md Scaffold apm-wo-analysis repository 2026-05-28 16:13:13 -04:00
README.md Update README for Phase 4 Slack surfaces 2026-05-28 17:49:57 -04:00

apm-wo-analysis

Daily analysis of Amazon APM work-order "Last Comment" data for Sea Haven facility ops. A curated daily filter-view export (~350 work orders) is classified on two axes, pushed to Slack, and surfaced in a self-hosted Grafana dashboard. Replaces a legacy Google Apps Script + versioned-Google-Sheet workflow.

This is a sibling concern to the apm@ email pipeline in procurement-ingest — it consumes a different feed (the manual export) and does not read those tables. There is deliberately no DynamoDB: this is an analytics workload backed by S3 + Athena (Grafana cannot query DynamoDB).

  • Account / region: 328440206208 / us-east-1
  • IaC: CDK (Python), aws-cdk-lib==2.253.1. Lambdas Python 3.12, ARM64.

Architecture

APM export (xlsx/csv)
  → S3 raw/  (direct upload OR local launchd drop-folder)
      → classifier Lambda (HTML strip + two-axis classify, Haiku fallback)
          → S3 analytics/dt=YYYY-MM-DD/  (per-WO daily snapshot, Parquet)
                → Glue table → Athena → Grafana (self-hosted EC2, VPN-only, kiosk)
          → slack-post Lambda (reads today + yesterday partitions)
                → daily summary post  [📊 Open dashboard button]
                → standalone batched 3rd-escalation alert (suppressed if zero)

Two CDK stacks:

Stack Resources
apm-wo-analysis-pipeline S3 exports bucket, classifier + slack-post Lambdas, Glue database, Athena workgroup, IAM
apm-wo-analysis-grafana EC2 (Grafana OSS), internal ALB, security group, Route53, Athena datasource IAM role

The classification model

Always two-axis, never comment-only. The legacy script's central flaw was reading only the comment while ignoring WO Status + Hold Reason, which left ~17% in "Other". The two-axis model cuts that to ~9% before any AI — and on the real 347-row export the current implementation lands "Other" at 5.2% (18 rows) with 18 mismatches flagged.

  • Axis 1 — comment intent: regex over the HTML-stripped Last Comment, most-specific first (escalations → SIM ticket → vendor no-show → scheduling → reports → completion → … → other).
  • Axis 2 — structured state: Hold Reason → category and WO Status signals (RCAN→Cancelled, H corroborates On Hold, IP/R/RR in-flight).
  • Resolution: comment intent wins when confident → else structured state → else Other. A Claude Haiku fallback (Secrets Manager) is reserved for ambiguous free-text with no structured signal.
  • Mismatch detector (a feature): flags when comment intent contradicts structured state. Surfaced, never suppressed.

The authoritative spec lives in CLAUDE.md; the implementation is in lambdas/classifier/classify.py (owned by the classifier-engineer agent). The S3-triggered lambdas/classifier/handler.py parses each export, classifies every non-blank-comment row, writes a per-WO Parquet snapshot to analytics/dt=YYYY-MM-DD/ (registering the Glue partition via awswrangler), and emits a summary.json for the Phase 4 slack-post Lambda.

Repository layout

cdk/
  app.py                 CDK entry point — instantiates both stacks
  cdk.json
  requirements.txt       aws-cdk-lib==2.253.1, constructs>=10.6.0
  stacks/
    pipeline_stack.py    S3, Lambdas, Glue, Athena, IAM
    grafana_stack.py     VPC import, EC2, ALB, SG, Route53, datasource role
lambdas/
  classifier/            S3-triggered: parse → two-axis classify → Parquet
  slack_post/            builds + posts the daily summary and alert
grafana/
  provisioning/          Athena datasource + dashboard provider (as code)
  dashboards/            committed dashboard JSON (source of truth)
scripts/                 local drop-folder uploader + launchd plist
tests/                   classifier smoke test
docs/BUILD.md            phased, end-to-end build guide

Configuration

Where What
Secrets Manager apm-wo-analysis/anthropic-api-key (Haiku fallback); apm-wo-analysis/slack-credentials = { botToken, signingSecret, channelId } (reused Slack app).
SSM Parameter Store /apm-wo-analysis/grafana-base-url (the 📊 dashboard button / modal overflow link; ops-editable).
GitHub repo secret AWS_DEPLOY_ROLE_ARN — the OIDC deploy role githubdeploy-apm-wo-analysis.

No secrets in Lambda environment variables.

Ingestion (no email)

The export reaches S3 by direct upload or a local drop-folder, never SES/email.

  • Direct: aws s3 cp ./export.xlsx s3://apm-wo-analysis-exports-328440206208/raw/

  • Drop-folder (optional zero-touch): a launchd agent (scripts/apm-wo-uploader.sh

    • scripts/com.seahaven.apm-wo-uploader.plist) that watches ~/apm-wo-drop/, uploads new .xlsx/.csv files to raw/, and archives them to uploaded/. It uploads with the scoped apm-wo-drop AWS profile (IAM user apm-wo-drop-uploader — s3:PutObject on raw/* only).

    Install (the runnable copy must live outside ~/Documents — macOS TCC sandbox; a repo-path script fails silently with LastExitStatus=32256):

    install -d "$HOME/.local/bin" "$HOME/apm-wo-drop"
    cp scripts/apm-wo-uploader.sh "$HOME/.local/bin/apm-wo-uploader.sh"
    chmod +x "$HOME/.local/bin/apm-wo-uploader.sh"
    cp scripts/com.seahaven.apm-wo-uploader.plist "$HOME/Library/LaunchAgents/"
    launchctl load -w "$HOME/Library/LaunchAgents/com.seahaven.apm-wo-uploader.plist"
    

    Re-copy the script to ~/.local/bin after editing the repo source. Configure the profile once with the uploader's access key: aws configure --profile apm-wo-drop.

The classifier Lambda is S3-triggered on the raw/ prefix regardless of path (any .xlsx/.csv landing under raw/ invokes it).

Deployment

CI/CD via the org reusable workflows (no manual prod deploys):

  • CI (.github/workflows/ci.yaml) → ci-python-sam.yaml@main: ruff + cdk synth.
  • Deploy (.github/workflows/deploy.yaml) → cd-cdk.yaml@main: OIDC assume-role, cdk deploy --all, single-flight concurrency.

The OIDC deploy role must exist before the first deploy. Deploy order:

cd cdk && pip install -r requirements.txt
cdk deploy apm-wo-analysis-pipeline   # S3, Glue, Athena, Lambdas, IAM
cdk deploy apm-wo-analysis-grafana    # EC2, ALB, SG, Route53, datasource role

Local development

  • pyenv Python 3.12; ruff check + ruff format --check before pushing (hook-enforced).
  • Smoke-test the classifier against a real export before declaring any classification change done: ~/Downloads/_documents/Sheet1-1.xlsx.
  • cdk synth must pass in CI before merge.

Status

Phase 4 — Slack surfaces (in review). Build-out proceeds per docs/BUILD.md: ingestion → classifier → Glue/Athena → Slack → Grafana → docs.

  • Phase 0 scaffold — merged-pending (PR #6).
  • Phase 1 ingestion (S3 bucket, drop-folder uploader, OIDC deploy role) — deployed; PR #7 open.
  • Phase 2 classifier Lambda + Glue database + S3 raw/ trigger — implemented and cdk synth-green; PR #8 open (stacked on Phase 1, not yet deployed).
  • Phase 3 apm_wo_snapshots projection table + Athena workgroup — implemented and cdk synth-green; PR #9 open (stacked on Phase 2). The classifier writes pure Parquet and holds no Glue access (projection handles partitions).
  • Phase 4 Slack post + interactions Lambdas (daily summary, batched 3rd-escalation alert, drill-down modals on apm-wo.seahaven.com) — implemented and cdk synth-green; PR #10 open (stacked on Phase 3). App manifest in slack/manifest.yaml.
  • Phases 5–6 (Grafana, final docs) — not started.