diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 157473bc0..ce71db7cd 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.9.0 +version: 1.10.0 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: @@ -138,6 +138,7 @@ modules: - name: tutorials-cloud-logging - name: tutorials-aicore - name: tutorials-credstore + - name: tutorials-alert-notification - name: tutorials-srv-qa type: nodejs @@ -157,7 +158,7 @@ modules: - cp -r ../../hugo/assets ./hugo/assets - cp -r ../../hugo/data ./hugo/data - cp -r ../../hugo/i18n ./hugo/i18n - - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js srv/lib/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" + - bash -c "mkdir -p srv/jobs && mkdir -p srv/handlers && mkdir -p srv/lib/branch && mkdir -p srv/lib/runtime-config && mkdir -p srv/lib/prompts && mkdir -p srv/lib/kg && mkdir -p srv/mcp/prompts && cp ../../srv/lib/branch/condition.js ../../srv/lib/branch/engine.js ../../srv/lib/branch/ranker.js ../../srv/lib/branch/user-state.js ../../srv/lib/branch/loaders.js ../../srv/lib/branch/mission-detail.js ../../srv/lib/branch/slug-key.js ../../srv/lib/branch/decide.js ../../srv/lib/branch/joule-tool.js ../../srv/lib/branch/branch-telemetry.js ../../srv/lib/branch/group-by-alt.js ../../srv/lib/branch/profile-fields.js ../../srv/lib/branch/profile-override.js srv/lib/branch/ && cp ../../srv/lib/runtime-config/kg-settings.js ../../srv/lib/runtime-config/ui-events-settings.js ../../srv/lib/runtime-config/search-settings.js ../../srv/lib/runtime-config/navigator-settings.js ../../srv/lib/runtime-config/display-settings.js ../../srv/lib/runtime-config/tenant-settings.js srv/lib/runtime-config/ && cp ../../srv/lib/kg/on-demand-enqueue.js ../../srv/lib/kg/on-demand-cosine-rank.js srv/lib/kg/ && cp ../../srv/lib/credstore.js ../../srv/lib/secret-resolver.js ../../srv/lib/content-store.js ../../srv/lib/content-publish-session.js ../../srv/lib/resolve-tutorial-author.js ../../srv/lib/_tutorials-table.js ../../srv/lib/catalog-renderer.js ../../srv/lib/catalog-data.js ../../srv/lib/catalog-mission-hierarchy.js ../../srv/lib/chrome-shell.js ../../srv/lib/pipeline-log.js ../../srv/lib/legacy-id.js ../../srv/lib/embedding-pipeline.js ../../srv/lib/step-text-extractor.js ../../srv/lib/embedding-client.js ../../srv/lib/step-vectors.js ../../srv/lib/user-progress.js ../../srv/lib/co-completion.js ../../srv/lib/tutorial-centroid.js ../../srv/lib/tag-label-map.js ../../srv/lib/code-check-tool.js ../../srv/lib/code-check-prompt.js ../../srv/lib/code-check-handler.js ../../srv/lib/code-check-llm.js ../../srv/lib/code-check-step-loader.js ../../srv/lib/code-check-spec-publish.js ../../srv/lib/validate-answer-spec-publish.js ../../srv/lib/category-classifier.js ../../srv/lib/category-classifier-llm.js ../../srv/lib/category-seed-embeddings.js ../../srv/lib/build-catalog-categories.js ../../srv/lib/chat-settings-resolver.js ../../srv/lib/kg-extract.js ../../srv/lib/kg-queries.js ../../srv/lib/kg-projection.js ../../srv/lib/kg-similarity.js ../../srv/lib/kg-cycles.js ../../srv/lib/kg-graph-rebuild.js ../../srv/lib/kg-sparql-client.js ../../srv/lib/kg-merge-pair.js ../../srv/lib/kg-concept-loader.js ../../srv/lib/kg-neighborhood-cache.js ../../srv/lib/kg-neighborhood-merge.js ../../srv/lib/kg-neighborhood-full-helpers.js ../../srv/lib/kg-other-resources-loader.js ../../srv/lib/kg-stamp-meta-text.js ../../srv/lib/kg-tutorial-teaches-map.js ../../srv/lib/kg-resource-type-config.js ../../srv/lib/kg-meta-formatters.js ../../srv/lib/discovery-mission-categories.js ../../srv/lib/external-content-ttl.js ../../srv/lib/recompute-tutorial-progress-bulk-sql.js ../../srv/lib/youtube-fetcher.js ../../srv/lib/homepage-events-merger.js ../../srv/lib/homepage-rss-fetcher.js ../../srv/lib/rss-parse.js ../../srv/lib/community-blogs-fetcher.js ../../srv/lib/community-blog-source-defaults.js ../../srv/lib/community-blogs-classifier.js ../../srv/lib/safe-fetch.js ../../srv/lib/curl-transport.js ../../srv/lib/khoros-transport.js ../../srv/lib/explainer-generator.js ../../srv/lib/_token-cost.js ../../srv/lib/metrics.js ../../srv/lib/alerting.js ../../srv/lib/relevance-classifier.js ../../srv/lib/relevance-seed-embeddings.js ../../srv/lib/relevance-keyword-rules.js ../../srv/lib/canonicalize-link.js ../../srv/lib/detect-language-en.js ../../srv/lib/kg-community-coverage.js srv/lib/ && cp ../../srv/handlers/categories-after-hooks.js ../../srv/handlers/completion-path-items-altgroup.js srv/handlers/ && mkdir -p srv && cp ../../srv/content-moderation-service.js srv/ && cp ../../srv/jobs/consolidate-concepts-job.js ../../srv/jobs/extract-concepts-job.js ../../srv/jobs/job-lock.js ../../srv/jobs/secret-expiry-check.js ../../srv/jobs/homepage-link-health.js ../../srv/jobs/kg-ondemand-job.js ../../srv/jobs/community-blogs-fetch-job.js ../../srv/jobs/community-blogs-classify-job.js ../../srv/jobs/fetch-news-job.js srv/jobs/ && cp ../../srv/lib/prompts/explainer-verb.md ../../srv/lib/prompts/explainer-shelf.md ../../srv/lib/prompts/explainer-shelf-entry.md ../../srv/lib/prompts/community-blogs-classifier.md srv/lib/prompts/ && cp ../../srv/mcp/prompts/summarize_mission_for_beginner.md ../../srv/mcp/prompts/generate_lab_exercise.md ../../srv/mcp/prompts/explain_concept.md ../../srv/mcp/prompts/suggest_learning_path.md srv/mcp/prompts/" - bash -c "node -e \"const p=require('./package.json'); p.dependencies=Object.assign(p.dependencies||{},{cheerio:'^1.2.0','@sap-ai-sdk/foundation-models':'^2.10.0'}); require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2));\"" properties: EXPOSE_CAP_UI: false @@ -421,3 +422,9 @@ resources: service: autoscaler service-plan: standard service-name: tutorials-autoscaler + + - name: tutorials-alert-notification + type: org.cloudfoundry.managed-service + parameters: + service: alert-notification + service-plan: standard diff --git a/.github/workflows/api-docs-drift.yml b/.github/workflows/api-docs-drift.yml index 2121ff6ce..b8d69dc39 100644 --- a/.github/workflows/api-docs-drift.yml +++ b/.github/workflows/api-docs-drift.yml @@ -18,13 +18,17 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' cache: npm - - run: npm ci + - name: Install dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} - name: Check /api-docs/ YAML ↔ testing-endpoints.md drift # #1040 follow-up. Every row in hugo/data/api_endpoints.yaml must # also appear (verbatim path OR every driftMarker) in the internal diff --git a/.github/workflows/cds-build-staging-check.yml b/.github/workflows/cds-build-staging-check.yml index 3344c3a75..86b9158ce 100644 --- a/.github/workflows/cds-build-staging-check.yml +++ b/.github/workflows/cds-build-staging-check.yml @@ -21,6 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -30,7 +31,10 @@ jobs: # `cds build --production` invokes the cds-compiler which lives in # @sap/cds-dk (devDependency). `npm ci` ensures it's available. # ~30s cold; ~5s with cache. - - run: npm ci + - name: Install dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} - name: Run CDS build staging check # Approach: regenerate the artifacts via `cds build --production`, # then `git diff --exit-code` against the staged outputs. Any diff --git a/.github/workflows/e2e-coverage-nudge.yml b/.github/workflows/e2e-coverage-nudge.yml index 8a0d3f4ad..dac4a7f75 100644 --- a/.github/workflows/e2e-coverage-nudge.yml +++ b/.github/workflows/e2e-coverage-nudge.yml @@ -23,6 +23,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - uses: actions/checkout@v4 with: @@ -32,7 +33,10 @@ jobs: with: node-version: '22' cache: npm - - run: npm ci + - name: Install dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} - name: Run e2e coverage nudge (advisory) env: E2E_NUDGE_BASE: origin/${{ github.base_ref }} diff --git a/.github/workflows/regen-alerts-lockfile.yml b/.github/workflows/regen-alerts-lockfile.yml new file mode 100644 index 000000000..3e0cc842e --- /dev/null +++ b/.github/workflows/regen-alerts-lockfile.yml @@ -0,0 +1,46 @@ +name: Regenerate lockfile (alerts dep) + +# One-shot helper: resolves @sap-tutorials/cds-alert-notification from the +# private GitHub Packages registry and writes it into package-lock.json, then +# commits the updated lockfile back to the branch. Uses the built-in +# GITHUB_TOKEN (packages:read to fetch the package, contents:write to push) — +# no PAT, no App key. Run via "Run workflow" against the PR branch, then delete +# this file once the lockfile is committed. + +on: + workflow_dispatch: {} + +permissions: + contents: write + packages: read + +jobs: + regen-lock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + + - uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://npm.pkg.github.com' + scope: '@sap-tutorials' + + - name: Resolve dep into package-lock.json + run: npm install --package-lock-only --no-audit --no-fund + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit updated lockfile + run: | + if git diff --quiet package-lock.json; then + echo "package-lock.json already up to date — nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package-lock.json + git commit -m "chore(alerts): add @sap-tutorials/cds-alert-notification to package-lock.json [skip ci]" + git push origin HEAD:${{ github.ref_name }} diff --git a/.github/workflows/schema-drift-check.yml b/.github/workflows/schema-drift-check.yml index 9eb266b1f..1df720eaf 100644 --- a/.github/workflows/schema-drift-check.yml +++ b/.github/workflows/schema-drift-check.yml @@ -10,9 +10,13 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '22' } - - run: npm ci + - name: Install dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} - run: npx tsx scripts/check-qa-schema-drift.ts diff --git a/.github/workflows/srv-qa-cp-list-check.yml b/.github/workflows/srv-qa-cp-list-check.yml index 0f7eead29..3cb34bd6f 100644 --- a/.github/workflows/srv-qa-cp-list-check.yml +++ b/.github/workflows/srv-qa-cp-list-check.yml @@ -24,6 +24,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -33,6 +34,9 @@ jobs: # `npm ci` is overkill (we only need tsx + the source tree). The # check has no runtime SAP deps and doesn't touch gen/. But ci # is the safe choice; ~30s on a warm cache. - - run: npm ci + - name: Install dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} - name: Run srv-qa cp-list check run: npx tsx scripts/check-srv-qa-cp-list.ts diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 7527e370e..e2de85229 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -21,6 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -39,6 +40,8 @@ jobs: hugo version - name: Install dependencies run: npm ci --no-audit --no-fund + env: + NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} - name: Setup nested workspaces (hugo-apps, app/explore) + rebuild native bindings run: npm run setup - name: Run unit tests diff --git a/.github/workflows/validate-api-docs-yaml.yml b/.github/workflows/validate-api-docs-yaml.yml index 1245ae259..1387776b3 100644 --- a/.github/workflows/validate-api-docs-yaml.yml +++ b/.github/workflows/validate-api-docs-yaml.yml @@ -17,13 +17,17 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' cache: npm - - run: npm ci + - name: Install dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN || secrets.GITHUB_TOKEN }} - name: Validate api-docs.yaml schema # Phase 4.5 (#746). Schema-only check (no DB hit; ~1 sec). # Rejects any PR that edits db/data/api-docs.yaml in a way that diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..a1ac86733 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@sap-tutorials:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} diff --git a/docs/developers/architecture/observability.md b/docs/developers/architecture/observability.md index 78ef2a09d..c9b68a998 100644 --- a/docs/developers/architecture/observability.md +++ b/docs/developers/architecture/observability.md @@ -99,3 +99,182 @@ Both retention jobs use `job-lock.js`; the rollup writer does NOT - Spec: [`docs/superpowers/specs/2026-07-02-805-observability-instrumentation-design.md`](../../superpowers/specs/2026-07-02-805-observability-instrumentation-design.md) - Issue: [#805](https://github.com/sap-tutorials/tutorials-ims/issues/805) + +--- + +# Alerting (SAP Alert Notification Service) + +The alerting layer escalates a **subset of failures that need a human** to the +`devrel-oncall` distribution list via SAP Alert Notification Service (ANS). It +sits **beside** the metrics module and structured logs — it does not replace +them. The metrics module is unchanged; alerting adds a push signal on the +failure paths where passive dashboards are not enough. + +## Implementation + +`srv/lib/alerting.js` exports a single `raise(input)` helper. It is: + +- **Fail-open** — all errors are caught and warn-logged; the alert never throws + into or blocks the call path it watches. +- **Default off** — no-ops unless `ALERTS_ENABLED=true` is set in the CF + environment. +- **Memoised** — `cds.connect.to('alerts')` is called once; the promise is + cleared on error to allow reconnect on the next raise. + +The helper mirrors `metrics.js` in calling convention: import as a namespace, +call the exported function directly, never `await` from the failure path (use +`void alerting.raise(...)`). + +## Alerted failure paths + +| Hook site | File | `eventType` | When raised | +|---|---|---|---| +| Content-publish soft-reject | `srv/lib/content-publish-session.js` `commitSession` | `PublishRejected` | `outcome === 'rejected'` — one or more slug reverts were blocked; content partially published | +| Scheduled job failure | `srv/jobs/scheduler.js` `runWithLock` catch | `ScheduledJobFailed` | Any scheduled job throws; `resource.resourceName` = job name; deduplicates per job via ANS `dedupWindowMs` | +| Rebuild dispatch failure | `srv/lib/rebuild-trigger.js` dispatch catch | `RebuildDispatchFailed` | GitHub Actions dispatch throws; admin save already succeeded; next trigger picks up the miss | + +All three hooks use `severity: 'ERROR'` and `category: 'ALERT'`. +`ScheduledJobFailed` covers **every** scheduled job (metrics-rollup, KG +nightly jobs, community-events refresh, etc.) through the single chokepoint in +`runWithLock`. + +## Configuration + +In `package.json` `cds.requires.alerts`: + +```json +"alerts": { + "impl": "@sap-tutorials/cds-alert-notification", + "kind": "alert-notification-console", + "[test]": { "kind": "alert-notification-memory" }, + "[hybrid]": { "kind": "alert-notification" }, + "[production]": { "kind": "alert-notification" }, + "channels": ["email:devrel-oncall"], + "routes": [{ "minSeverity": "ERROR", "channels": ["email:devrel-oncall"] }], + "eventTypes": ["PublishRejected", "ScheduledJobFailed", "RebuildDispatchFailed"], + "dedupWindowMs": 300000 +} +``` + +- `alert-notification-console` — local `cds watch` logs alerts to stdout only + (no ANS traffic, no quota). +- `alert-notification-memory` — unit-test profile; alerts accumulate in memory + for assertion. +- `alert-notification` — hybrid/production; posts to the bound ANS service + instance via `cds.outboxed()`. +- `dedupWindowMs: 300000` — 5-minute dedup window; repeated failures of the + same job within the window produce one email, not a flood. + +## Plugin dependency + +The plugin is `@sap-tutorials/cds-alert-notification` v1.0.0, published **privately +to the org's GitHub Packages** npm registry and consumed by version: + +``` +"@sap-tutorials/cds-alert-notification": "^1.0.0" +``` + +Because the `@sap-tutorials` scope is private, installs need a scope→registry +mapping and a token with `read:packages`. The repo's root `.npmrc` provides the +mapping and reads the token from the environment: + +```ini +@sap-tutorials:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} +``` + +- **CI:** the four `npm ci` jobs (`unit`, `check`/`cds-build-staging-check`, + `check-cp-list`/`srv-qa-cp-list-check`, `validate`) mint a token via the repo's + existing GitHub App (`actions/create-github-app-token`, gated on + `vars.USE_GITHUB_APP`) and export it as `NODE_AUTH_TOKEN`, falling back to a + `PACKAGES_READ_TOKEN` secret. Each job also declares `permissions: packages: read`. +- **Local dev / CF deploy:** set `NODE_AUTH_TOKEN` to a token with `read:packages` + on the `sap-tutorials` org before `npm install`. + +## Feature flag + +- `ALERTS_ENABLED` (default `false` / absent) — master switch for the helper. + Set to `'true'` to enable; any other value (including unset) silently skips + every `raise()` call. + +## Operator post-merge checklist + +These steps cannot be performed from a PR and must be completed after the MTA +is deployed. + +**1. Confirm the GitHub App (or `PACKAGES_READ_TOKEN`) can read GitHub Packages.** +The four CI `npm ci` jobs authenticate to `@sap-tutorials`'s private GitHub +Packages registry via the App token (`vars.USE_GITHUB_APP == 'true'` + +`TUTORIALS_APP_ID`/`TUTORIALS_APP_PRIVATE_KEY`) or the `PACKAGES_READ_TOKEN` +fallback secret. Verify: (a) the App installation on `sap-tutorials` grants +**packages:read** and covers the `cds-alert-notification` repo, OR (b) +`PACKAGES_READ_TOKEN` exists with `read:packages`. Also confirm the CF deploy +pipeline exports a `NODE_AUTH_TOKEN` with the same scope before its `npm install`. +If neither is in place, `npm ci`/`npm install` fails to fetch the plugin. + +**2. Publish the plugin to GitHub Packages.** +The plugin must be published before this consumer can install v1.0.0. On the +`sap-tutorials/cds-alert-notification` repo, cut a `v1.0.0` GitHub Release — its +`publish.yml` workflow publishes to GitHub Packages (private). Confirm the +package appears under the org's Packages tab before deploying tutorials-ims. + +**3. Regenerate `package-lock.json`.** +`package.json` now references `@sap-tutorials/cds-alert-notification` by version, +but the committed lockfile predates that change (the authoring workstation could +not reach the private registry to resolve it). In an environment with a +`read:packages` `NODE_AUTH_TOKEN` for the `sap-tutorials` org, run `npm install` +to add the resolved entry and commit the updated `package-lock.json`. Until then, +`npm ci` jobs fail on the package.json/lockfile mismatch. + +**4. Deploy the MTA (v1.10.0).** +`.deploy/mta.yaml` declares `tutorials-alert-notification` as a managed +`alert-notification` service (plan `standard`). The `mbt build` + `cf deploy` +run provisions the instance and binds it to `tutorials-srv`. No manual `cf +create-service` is needed. + +**5. Bind the email action to the `devrel-oncall` distribution list.** +The MTA creates the ANS **instance** but does NOT configure email routing — +that requires a post-deploy step in the ANS cockpit (or via the plugin's +generated `provision.sh`). Open the ANS cockpit for the `tutorial-system` +subaccount, locate the `tutorials-alert-notification` instance, and create an +email ACTION pointing to the real `devrel-oncall` distribution-list address. +Wire it to the `devrel-oncall` CONDITION (minSeverity ERROR). Without this step +the instance is bound but no emails are sent. + +**6. Enable alerting.** + +```bash +cf target -s dev # confirm space before set-env +cf set-env tutorials-srv ALERTS_ENABLED true +cf restart tutorials-srv +``` + +**7. Live-verify one alert end-to-end.** +Trigger a known failure (e.g. a publish-reject via the admin UI with a +deliberately bad slug, or force a scheduled job error in DEV) and confirm the +email arrives at the `devrel-oncall` address. This is the **one path not proven +by any automated test** — the unit tests assert the helper contract and envelope +shapes in memory, but `cds.outboxed()` posting to a real ANS endpoint has not +been exercised against a live CAP runtime. This live-verify is **mandatory** +before declaring the integration done. + +**8. Confirm Node runtime floor.** +`package.json` now declares `"engines": { "node": ">=22.12" }` (the plugin's +requirement). Verify the CF buildpack runtime satisfies this before deploying +to PROD. The CI pipeline already runs Node 22; the CF Node.js buildpack default +should be ≥22.12 — confirm with `cf env tutorials-srv | grep VCAP_APPLICATION` +after deploy and check the buildpack version log. + +## Surfaces + +- CF logs — `cds.log('alerting')` warn lines on any raise failure (e.g. ANS + unreachable, `ALERTS_ENABLED` off). +- ANS cockpit — alert history under the `tutorials-alert-notification` + instance. +- No admin-UI tile in v1 — the metrics module's existing `/admin-ui/#metrics` + is unchanged; alerting is a push channel only. + +## References + +- Spec: [`docs/superpowers/specs/2026-08-03-ans-integration-tutorials-ims/spec.md`](../../superpowers/specs/2026-08-03-ans-integration-tutorials-ims/spec.md) (if present) +- Issue: ANS integration tracking issue (see PR description for link) diff --git a/docs/superpowers/plans/2026-08-03-ans-integration-tutorials-ims.md b/docs/superpowers/plans/2026-08-03-ans-integration-tutorials-ims.md new file mode 100644 index 000000000..eb9e02c11 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-ans-integration-tutorials-ims.md @@ -0,0 +1,526 @@ +# ANS Integration into tutorials-ims — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire the `@sap-devrel/cds-alert-notification` plugin into tutorials-ims so three operational failure paths (publish-reject, any scheduled-job failure, rebuild-dispatch failure) push email alerts to a DevRel on-call list — dark behind `ALERTS_ENABLED`, provisioned via MTA, never able to break the paths it watches. + +**Architecture:** Add the plugin as a git-dep + an `alerts` block in `cds.requires`. A thin `srv/lib/alerting.js` helper (mirrors `metrics.js`: namespace import, never-throws, env kill-switch) is called at three existing failure sites beside the current signal. An `alert-notification` managed-service resource is added to `.deploy/mta.yaml`. Everything ships dark; deploy + email-action binding + flag flip are operator-owned post-merge. + +**Tech Stack:** CAP Node.js, `@sap/cds`, the ANS plugin (v1.0.0), Vitest (in-memory sink via `[test]` profile), MTA/CF. + +## Global Constraints + +- **Plugin version:** `github.tools.sap/developer-relations/cds-alert-notification#v1.0.0`. +- **Node floor:** plugin requires `>=22.12`; tutorials-ims is `>=22` / CI `NODE_VERSION: '22'` (satisfied). Tighten tutorials-ims `engines.node` to `>=22.12` in Task 1. +- **Fail-open, always:** no alerting path may throw into publish / jobs / rebuild. Plugin never throws AND helper wraps in try/catch. +- **Dark by default:** all emission gated on `process.env.ALERTS_ENABLED === 'true'` (matches `metrics.js` kill-switch + `KG_*` flag style). Default off. +- **Config keys are runtime-read:** `channels`, `routes`, `dedupWindowMs` are consumed live by the plugin (`service.js:29` → `routing.js`); `eventTypes`/`monitor` are build-time only. No inert keys. +- **Email channel is named, not addressed:** config uses `email:devrel-oncall`; the real distribution-list address is bound in the ANS action post-deploy, never in code. +- **MTA edits:** `.deploy/mta.yaml` only (root is legacy). **Minor** version bump (feature). +- **PR-gated:** feature branch → `gh pr create --draft`. Never direct-merge to main. No deploy from the PR. +- **Update pre-existing tests** that exercise the three failure sites (a new signal there can break them) — don't just add new tests. + +--- + +## File Structure + +- **Create** `srv/lib/alerting.js` — the fail-open alert helper (one responsibility: gated, safe `raise()`). +- **Create** `test/unit/alerting.test.js` — helper + hook-firing unit tests (in-memory sink). +- **Modify** `package.json` — add dependency, `cds.requires.alerts` block, tighten `engines.node`. +- **Modify** `srv/lib/content-publish-session.js:~516` — hook 1 (publish-reject). +- **Modify** `srv/jobs/scheduler.js:~170-174` — hook 2 (scheduler chokepoint). +- **Modify** `srv/lib/rebuild-trigger.js:~182` — hook 3 (rebuild-dispatch catch). +- **Modify** `.deploy/mta.yaml` — ANS managed-service resource + `tutorials-srv` binding + version bump. + +--- + +## Task 1: Dependency, config block, Node floor + +**Files:** +- Modify: `package.json` + +**Interfaces:** +- Consumes: nothing. +- Produces: the `alerts` service registered in `cds.env.requires` so `cds.connect.to('alerts')` resolves; profile-based sink kinds (console/memory/real). Later tasks depend on this being connectable. + +- [ ] **Step 1: Verify the Node floor is genuinely met** + +Run: `jq -r '.engines.node' package.json` and `grep -rn "NODE_VERSION\|node-version" .github/workflows/deploy.yml | head`. +Expected: `>=22` and `NODE_VERSION: '22'`. Confirm the plugin's `>=22.12` is satisfied by the runner's 22.x. If tutorials-ims declared `<22.12` anywhere, STOP and surface it. + +- [ ] **Step 2: Add the dependency and tighten engines** + +In `package.json`: +- `dependencies`: add `"@sap-devrel/cds-alert-notification": "github.tools.sap/developer-relations/cds-alert-notification#v1.0.0"`. +- `engines.node`: change `">=22"` → `">=22.12"` (match the plugin floor honestly). + +- [ ] **Step 3: Add the `alerts` block to `cds.requires`** + +In `package.json` `cds.requires` (sibling to `telemetry`): + +```jsonc +"alerts": { + "impl": "@sap-devrel/cds-alert-notification", + "kind": "alert-notification-console", + "[test]": { "kind": "alert-notification-memory" }, + "[hybrid]": { "kind": "alert-notification" }, + "[production]": { "kind": "alert-notification" }, + "channels": ["email:devrel-oncall"], + "routes": [{ "minSeverity": "ERROR", "channels": ["email:devrel-oncall"] }], + "eventTypes": ["PublishRejected", "ScheduledJobFailed", "RebuildDispatchFailed"], + "dedupWindowMs": 300000 +} +``` + +- [ ] **Step 4: Install and verify the plugin resolves** + +Run: `npm install` then `node -e "const cds=require('@sap/cds'); require('@sap-devrel/cds-alert-notification'); console.log('alerts kind:', cds.env.requires.alerts?.kind)"` +Expected: prints `alerts kind: alert-notification-console` (base profile) without throwing. If `require` throws `ERR_REQUIRE_ESM`, the Node floor is wrong — STOP. + +- [ ] **Step 5: Confirm the existing suite still green (plugin load is inert until used)** + +Run: `npm test 2>&1 | tail -5` +Expected: same pass count as before this task (plugin present but unused). + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json +git commit -m "feat(alerts): add cds-alert-notification plugin dep + alerts config block" +``` + +--- + +## Task 2: The `alerting.js` fail-open helper + +**Files:** +- Create: `srv/lib/alerting.js` +- Test: `test/unit/alerting.test.js` + +**Interfaces:** +- Consumes: `cds.connect.to('alerts')` (from Task 1). +- Produces: `raise(input) → Promise` — gated on `ALERTS_ENABLED`, never throws, caches the service connection. `input` is `{ eventType, severity, category, subject, body, resource:{resourceName,resourceType}, tags? }`. Hooks in Tasks 4-6 call this. + +- [ ] **Step 1: Write the failing test** + +```js +// test/unit/alerting.test.js +import { describe, it, expect, beforeEach, vi } from 'vitest' +import cds from '@sap/cds' + +describe('alerting helper', () => { + beforeEach(() => { vi.resetModules(); delete process.env.ALERTS_ENABLED }) + + it('no-ops when ALERTS_ENABLED is not set (never connects)', async () => { + const spy = vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn() }) + const { raise } = await import('../../srv/lib/alerting.js') + await raise({ eventType: 'X', severity: 'ERROR' }) + // connect.to must not be called when disabled + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('routes to the alerts service when enabled', async () => { + process.env.ALERTS_ENABLED = 'true' + const raiseSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: raiseSpy }) }) + const { raise } = await import('../../srv/lib/alerting.js') + await raise({ eventType: 'PublishRejected', severity: 'ERROR' }) + expect(raiseSpy).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'PublishRejected' })) + }) + + it('never throws when the service.raise throws (fail-open)', async () => { + process.env.ALERTS_ENABLED = 'true' + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: vi.fn().mockRejectedValue(new Error('boom')) }) }) + const { raise } = await import('../../srv/lib/alerting.js') + await expect(raise({ eventType: 'X', severity: 'ERROR' })).resolves.toBeUndefined() + }) + + it('never throws when connect itself throws (fail-open)', async () => { + process.env.ALERTS_ENABLED = 'true' + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockRejectedValue(new Error('no binding')) }) + const { raise } = await import('../../srv/lib/alerting.js') + await expect(raise({ eventType: 'X', severity: 'ERROR' })).resolves.toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/alerting.test.js` +Expected: FAIL — `../../srv/lib/alerting.js` not found. + +- [ ] **Step 3: Write the helper** + +```js +// srv/lib/alerting.js +// Fail-open push-alert helper. Mirrors metrics.js: namespace import, never throws, +// env kill-switch. Sits BESIDE existing failure signals (metrics/log/PipelineLog), +// never replaces them. Default OFF (ALERTS_ENABLED !== 'true'). +import cds from '@sap/cds' + +const LOG = cds.log('alerting') +let svcPromise // memoised connection + +function isEnabled () { + return process.env.ALERTS_ENABLED === 'true' +} + +export async function raise (input) { + if (!isEnabled()) return + try { + svcPromise ??= cds.connect.to('alerts') + const svc = await svcPromise + await svc.raise(input) + } catch (e) { + // Never propagate — alerting must not break the path it watches. + svcPromise = undefined // allow a later reconnect attempt + LOG.warn('alert raise failed (swallowed):', e?.message ?? e) + } +} + +// Test-only: reset the memoised connection between cases. +export function _resetForTest () { svcPromise = undefined } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run test/unit/alerting.test.js` +Expected: PASS (4 tests). If the `cds.connect` getter-spy approach fights the CAP test harness, fall back to injecting a connector: export `raise(input, { connect = () => cds.connect.to('alerts') } = {})` and have tests pass a fake `connect`. Keep the public one-arg call site unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/alerting.js test/unit/alerting.test.js +git commit -m "feat(alerts): fail-open alerting helper gated on ALERTS_ENABLED" +``` + +--- + +## Task 3: `srv-qa` cp-list audit (guard rail, no code) + +**Files:** +- Inspect: `.deploy/mta.yaml` (`srv-qa` module `cp` list) + +**Interfaces:** +- Consumes: knowledge that Task 2 created `srv/lib/alerting.js`. +- Produces: a verified confirmation that `srv-qa` does NOT need `alerting.js` (or, if it does, the cp-list entry). Prevents a QA-boot crash at MTA deploy. + +- [ ] **Step 1: Walk transitive `./` imports into alerting.js** + +Run: `grep -rn "alerting" srv/lib/content-store.js srv/lib/content-publish-session.js` — confirm whether `content-store.js` (the srv-qa entry per CLAUDE.md) transitively imports `alerting.js`. It should NOT (only the three hook files do, and hooks 2/3 are outside content-store's import graph). If `content-publish-session.js` (hook 1) is in srv-qa's graph, then `alerting.js` IS a transitive dep and MUST be in srv-qa's `cp` list. + +- [ ] **Step 2: Check srv-qa's cp list** + +Read `.deploy/mta.yaml`, find the `srv-qa` module's `build-parameters.copy`/`cp` list. Determine if `srv/lib/alerting.js` needs to be there (only if reachable from `content-store.js`'s import graph). + +- [ ] **Step 3: Record the finding** + +If `alerting.js` is reachable from srv-qa's entrypoint, it will be added in Task 7 alongside the mta.yaml edits. If not, note "srv-qa does not import alerting.js — no cp entry needed" in the commit body of Task 7. No standalone commit for this task — it's a gate feeding Task 7. + +--- + +## Task 4: Hook 1 — publish-reject + +**Files:** +- Modify: `srv/lib/content-publish-session.js` (near line 516, in `commitSession`) +- Test: `test/unit/alerting.test.js` (extend) + +**Interfaces:** +- Consumes: `raise()` from Task 2. +- Produces: a `PublishRejected` alert when a commit soft-rejects reverts. No new exports. + +- [ ] **Step 1: Write the failing test** + +Add to `test/unit/alerting.test.js` a test that imports the alerting helper with a fake service, drives the publish-reject branch, and asserts `raise` was called with `eventType:'PublishRejected'`. Because `commitSession` is a large function, the practical test is at the helper-contract level plus a focused assertion that the hook builds the right envelope. Add: + +```js +it('publish-reject envelope shape is correct', async () => { + process.env.ALERTS_ENABLED = 'true' + const raiseSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: raiseSpy }) }) + const { raise } = await import('../../srv/lib/alerting.js') + // Simulate what the hook constructs: + await raise({ + eventType: 'PublishRejected', severity: 'ERROR', category: 'ALERT', + subject: 'Content publish rejected 2 slug(s)', + body: 'Rejected reverts: a, b', + resource: { resourceName: 'content-publish', resourceType: 'service' } + }) + expect(raiseSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'PublishRejected', category: 'ALERT', + resource: { resourceName: 'content-publish', resourceType: 'service' } + })) +}) +``` + +(A full integration test of `commitSession` is out of scope for the unit suite; the hybrid suite exercises the real publish path. The envelope-shape contract test guards the hook's payload.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/alerting.test.js` +Expected: PASS actually (this asserts the helper) — so instead, FIRST verify the hook site is NOT yet wired: `grep -n "alerting" srv/lib/content-publish-session.js` → expect no match. That's the "red" state for the hook. + +- [ ] **Step 3: Wire the hook** + +At top of `srv/lib/content-publish-session.js` (with the other imports, near line 7): +```js +import * as alerting from './alerting.js'; +``` +In `commitSession`, right after the `metrics.counter(...'publish.commit.reject'...)` call (~line 516), inside the same `outcome === 'rejected'` condition: +```js +if (outcome === 'rejected') { + alerting.raise({ + eventType: 'PublishRejected', + severity: 'ERROR', + category: 'ALERT', + subject: `Content publish rejected ${rejectedReverts.length} slug(s)`, + body: `Rejected reverts: ${rejectedReverts.join(', ')}`, + resource: { resourceName: 'content-publish', resourceType: 'service' } + }); // fire-and-forget; helper is fail-open, do NOT await-block the commit path +} +``` +Note: do NOT `await` if the surrounding code is latency-sensitive at that point; the helper swallows errors. If the function is already async and a floating promise triggers lint, `void alerting.raise({...})`. + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run test/unit/alerting.test.js` (helper/shape green) AND `npm test 2>&1 | tail -5` (full suite — confirm no existing publish test broke from the new call). +Expected: all green. If a pre-existing content-publish test now fails because `cds.connect.to('alerts')` is attempted, that test runs with `ALERTS_ENABLED` unset so `raise` no-ops before connect — it should not break. If it does, the test had `ALERTS_ENABLED` leaking; fix the test's env isolation. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/content-publish-session.js test/unit/alerting.test.js +git commit -m "feat(alerts): raise PublishRejected on publish soft-reject" +``` + +--- + +## Task 5: Hook 2 — scheduler chokepoint (covers every job) + +**Files:** +- Modify: `srv/jobs/scheduler.js` (in `runWithLock` failure path, ~line 170-174) +- Test: `test/unit/alerting.test.js` (extend) + +**Interfaces:** +- Consumes: `raise()` from Task 2. +- Produces: a `ScheduledJobFailed` alert (resourceName = the failing jobName) on ANY scheduled-job failure. No new exports. + +- [ ] **Step 1: Verify hook not yet present (red state)** + +Run: `grep -n "alerting" srv/jobs/scheduler.js` +Expected: no match. + +- [ ] **Step 2: Add the envelope-shape test** + +Add to `test/unit/alerting.test.js`: +```js +it('scheduled-job-failed envelope uses jobName as resourceName', async () => { + process.env.ALERTS_ENABLED = 'true' + const raiseSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: raiseSpy }) }) + const { raise } = await import('../../srv/lib/alerting.js') + await raise({ + eventType: 'ScheduledJobFailed', severity: 'ERROR', category: 'ALERT', + subject: 'Scheduled job failed: kg-pagerank-job', + body: 'TypeError: boom', + resource: { resourceName: 'kg-pagerank-job', resourceType: 'job' } + }) + expect(raiseSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'ScheduledJobFailed', + resource: { resourceName: 'kg-pagerank-job', resourceType: 'job' } + })) +}) +``` + +- [ ] **Step 3: Wire the hook** + +Import at top of `srv/jobs/scheduler.js`: +```js +import * as alerting from '../lib/alerting.js'; +``` +In `runWithLock`'s failure path (right after `LOG.error(\`Job ${jobName} failed:\`, errorMessage)` / `logPipelineEnd(logId, 'FAILED', ...)`, ~line 173-174): +```js +alerting.raise({ + eventType: 'ScheduledJobFailed', + severity: 'ERROR', + category: 'ALERT', + subject: `Scheduled job failed: ${jobName}`, + body: String(errorMessage), + resource: { resourceName: jobName, resourceType: 'job' } +}); // fail-open, non-blocking +``` +The plugin's 5-min dedup keyed on `eventType+resourceName` means a job failing every tick emits at most one email per 5 min per job. + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run test/unit/alerting.test.js` and `npm test 2>&1 | tail -5`. +Expected: all green. Check specifically any scheduler unit test — with `ALERTS_ENABLED` unset the raise no-ops. Fix env isolation if a scheduler test leaks the flag. + +- [ ] **Step 5: Commit** + +```bash +git add srv/jobs/scheduler.js test/unit/alerting.test.js +git commit -m "feat(alerts): raise ScheduledJobFailed from scheduler chokepoint (all jobs)" +``` + +--- + +## Task 6: Hook 3 — rebuild-dispatch failure + +**Files:** +- Modify: `srv/lib/rebuild-trigger.js` (the catch at ~line 182) +- Test: `test/unit/alerting.test.js` (extend) + +**Interfaces:** +- Consumes: `raise()` from Task 2. +- Produces: a `RebuildDispatchFailed` alert in the debounced-dispatch catch. No new exports. + +- [ ] **Step 1: Verify hook not present + confirm connect works in setTimeout context** + +Run: `grep -n "alerting\|setTimeout\|cds.connect" srv/lib/rebuild-trigger.js` +Expected: no `alerting` match. Note the module uses `console.*` and the failing dispatch runs inside a `setTimeout` (non-request context). `cds.connect.to` is safe outside a request — but the helper is fail-open regardless, so a connect failure here just logs. + +- [ ] **Step 2: Add the envelope-shape test** + +Add to `test/unit/alerting.test.js`: +```js +it('rebuild-dispatch-failed envelope shape is correct', async () => { + process.env.ALERTS_ENABLED = 'true' + const raiseSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: raiseSpy }) }) + const { raise } = await import('../../srv/lib/alerting.js') + await raise({ + eventType: 'RebuildDispatchFailed', severity: 'ERROR', category: 'ALERT', + subject: 'Rebuild dispatch failed', + body: 'GitHub dispatch 500', + resource: { resourceName: 'rebuild-dispatch', resourceType: 'service' } + }) + expect(raiseSpy).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'RebuildDispatchFailed' })) +}) +``` + +- [ ] **Step 3: Wire the hook** + +`rebuild-trigger.js` uses `console.*` and may be CommonJS or ESM — match the file's existing module style for the import (check the top of the file; if it uses `require`, use `const alerting = require('./alerting.js')` — but `alerting.js` is ESM, so if `rebuild-trigger.js` is CJS, use a dynamic `import('./alerting.js')` inside the catch instead). In the catch block (~line 181-185, currently `console.error('[rebuild-trigger] dispatch failed:', ...)`), add after the existing console.error: +```js +import('./alerting.js').then(a => a.raise({ + eventType: 'RebuildDispatchFailed', + severity: 'ERROR', + category: 'ALERT', + subject: 'Rebuild dispatch failed', + body: String(err?.message ?? err), + resource: { resourceName: 'rebuild-dispatch', resourceType: 'service' } +})).catch(() => {}); // fully fail-open, even the dynamic import +``` +(Dynamic import works from both CJS and ESM and keeps the catch non-blocking + never-throwing.) + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run test/unit/alerting.test.js` and `npm test 2>&1 | tail -5`. +Expected: all green. Any existing rebuild-trigger test runs with `ALERTS_ENABLED` unset → no-op. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/rebuild-trigger.js test/unit/alerting.test.js +git commit -m "feat(alerts): raise RebuildDispatchFailed in dispatch catch" +``` + +--- + +## Task 7: MTA provisioning + version bump + +**Files:** +- Modify: `.deploy/mta.yaml` + +**Interfaces:** +- Consumes: the Task 3 srv-qa finding. +- Produces: an `alert-notification` managed-service instance bound to `tutorials-srv` on next deploy. + +- [ ] **Step 1: Add the resource + binding** + +In `.deploy/mta.yaml`: +- Under `resources:`: +```yaml + - name: tutorials-alert-notification + type: org.cloudfoundry.managed-service + parameters: + service: alert-notification + service-plan: standard +``` +- Under the `tutorials-srv` module's `requires:`: +```yaml + - name: tutorials-alert-notification +``` + +- [ ] **Step 2: Apply the Task 3 srv-qa finding** + +If Task 3 found `alerting.js` reachable from srv-qa's `content-store.js` import graph, add `srv/lib/alerting.js` to the `srv-qa` module's `cp`/copy list. If not, do nothing here (note it in the commit body). + +- [ ] **Step 3: Bump the MTA version (minor — feature)** + +In `.deploy/mta.yaml`, bump the top-level `version:` minor (e.g. `X.Y.Z` → `X.(Y+1).0`). Confirm you're editing `.deploy/mta.yaml`, NOT the legacy root `mta.yaml`. + +- [ ] **Step 4: Validate the MTA descriptor parses** + +Run: `npx mbt validate -e .deploy/dev.mtaext 2>&1 | tail -10` if available, else `yq '.resources[] | select(.name=="tutorials-alert-notification")' .deploy/mta.yaml` to confirm the resource is well-formed YAML. +Expected: the resource block echoes back; no YAML parse error. + +- [ ] **Step 5: Commit** + +```bash +git add .deploy/mta.yaml +git commit -m "feat(alerts): provision alert-notification instance via MTA (minor bump) + +srv-qa cp-list: " +``` + +--- + +## Task 8: README/ops note + full-suite gate + +**Files:** +- Modify: `docs/developers/architecture/observability.md` (add an "Alerting (ANS)" section) + +**Interfaces:** +- Consumes: everything. +- Produces: the operator runbook for the post-merge steps. + +- [ ] **Step 1: Document the integration + post-merge steps** + +Add an "Alerting (SAP Alert Notification)" section to `docs/developers/architecture/observability.md` covering: the three alerted failure paths + their eventTypes; the `ALERTS_ENABLED` flag (default off); the plugin + config block; and the **operator post-merge checklist**: (1) deploy so MTA provisions the instance; (2) bind the email action to the real `devrel-oncall` distribution list in the ANS cockpit (or via the plugin's generated `provision.sh`); (3) `cf set-env tutorials-srv ALERTS_ENABLED true && cf restart tutorials-srv`; (4) live-verify one alert (trigger a publish-reject or a job failure, confirm the email lands) — this is the plugin's one unverified path (`cds.outboxed()` + real ANS POST). Note the metrics module is unchanged — alerting escalates the subset needing a human. + +- [ ] **Step 2: Full suite green** + +Run: `npm test 2>&1 | tail -6` +Expected: all green, pass count = prior + the new alerting tests. + +- [ ] **Step 3: Commit** + +```bash +git add docs/developers/architecture/observability.md +git commit -m "docs(alerts): observability doc + operator post-merge checklist" +``` + +--- + +## Self-Review (completed by plan author) + +**Spec coverage:** +- §3 dependency + config → Task 1. ✓ (config keys verified runtime-read: `service.js:29`→`routing.js`.) +- §3 Node floor → Task 1 Steps 1-2 (verified met: `>=22` + CI 22; tightened to `>=22.12`). ✓ +- §4 MTA provisioning + minor bump + srv-qa audit → Tasks 3 (audit) + 7 (apply). ✓ +- §4 email action is post-deploy, not MTA → Task 8 operator checklist. ✓ +- §5 helper + 3 hooks → Tasks 2, 4, 5, 6. ✓ (fail-open doubly guaranteed: helper try/catch + plugin never-throws.) +- §5 hook-3 setTimeout/CJS caveat → Task 6 Steps 1, 3 (dynamic import handles CJS+ESM, fully fail-open). ✓ +- §6 testing + guard existing tests → each hook task Step 4 checks the full suite + env-isolation note. ✓ +- §6 live-verify NOT in PR → Task 8 operator checklist. ✓ +- §7 delivery (draft PR, minor bump, no deploy) → Task 7 + handled at finish. ✓ +- §8 open items → all resolved pre-plan (Node met, config runtime-read) or assigned (srv-qa→T3/T7, gen/alerts additive→noted, connect-in-setTimeout→T6). ✓ + +**Placeholder scan:** No TBD/"handle errors"/"similar to". The one conditional is the srv-qa cp-list (Task 3 gates Task 7) — both branches spelled out. + +**Type consistency:** `raise(input)` signature identical across Tasks 2/4/5/6. Envelope field names (`eventType/severity/category/subject/body/resource.{resourceName,resourceType}`) match the plugin's verified contract and are uniform across all three hooks. `ALERTS_ENABLED` spelled identically throughout. + +**Known thin spot (surfaced honestly):** the unit tests assert the helper contract + envelope shapes, NOT the hooks firing inside the real `commitSession`/`runWithLock`/dispatch functions (those are large and better covered by the hybrid suite / live-verify). Each hook task verifies (a) the site was un-wired before (red), (b) the full suite stays green after wiring. True end-to-end proof is the operator live-verify in Task 8 — explicitly out of the PR's provable scope. diff --git a/docs/superpowers/specs/2026-08-03-ans-integration-tutorials-ims-design.md b/docs/superpowers/specs/2026-08-03-ans-integration-tutorials-ims-design.md new file mode 100644 index 000000000..6bde92ad2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-ans-integration-tutorials-ims-design.md @@ -0,0 +1,121 @@ +# Design: SAP Alert Notification integration into tutorials-ims (via `cds-alert-notification` plugin) + +**Date:** 2026-08-03 +**Status:** Approved (design), pending spec review → implementation plan +**Repo:** tutorials-ims (local `tutorials-poc`) +**Plugin:** `@sap-devrel/cds-alert-notification` v1.0.0 (github.tools.sap/developer-relations/cds-alert-notification) +**Follows:** the plugin's own design spec §8/§9 (tutorials-ims = first consumer) + +--- + +## 1. Goal + +Push **operational alerts** from tutorials-ims to a DevRel on-call **email** distribution list when key failures occur, using the reusable `cds-alert-notification` CAP plugin. Today these failures are pull-only (metrics/logs/PipelineLog); nobody is told. This wires push notification into three real failure paths, **dark behind a flag**, so the subset of failures that need a human reach one. + +## 2. Scope + +**In (all three real emit sites — capability #1 of the plugin):** +1. **Publish-reject** — `srv/lib/content-publish-session.js:516` (beside `metrics.counter('publish.commit.reject')`). +2. **Scheduled-job failure** — `srv/jobs/scheduler.js:170-174` (`runWithLock` failure path — a single chokepoint covering **every** scheduled job). +3. **Rebuild-dispatch failure** — `srv/lib/rebuild-trigger.js:182` (the catch that currently only `console.error`s, not rethrown). + +**Out (YAGNI / not applicable):** +- **Content-hash mismatch** — no server-side emit site exists in `srv/` (batch hash is returned to the client; verification is CI-side). Dropped. +- Platform-event monitoring subscriptions (plugin capability #2) — separate provisioning exercise. +- Admin-UI alert config surface. +- Actual deploy / flag flip / email-action binding — all post-merge, operator-owned. + +## 3. Dependency & config + +`package.json`: +- Dependency: `"@sap-devrel/cds-alert-notification": "github.tools.sap/developer-relations/cds-alert-notification#v1.0.0"`. +- New `cds.requires.alerts` block (mirrors the `telemetry` block's profile pattern): + +```jsonc +"alerts": { + "impl": "@sap-devrel/cds-alert-notification", + "kind": "alert-notification-console", + "[test]": { "kind": "alert-notification-memory" }, + "[hybrid]": { "kind": "alert-notification" }, + "[production]": { "kind": "alert-notification" }, + "channels": ["email:devrel-oncall"], + "routes": [{ "minSeverity": "ERROR", "channels": ["email:devrel-oncall"] }], + "eventTypes": ["PublishRejected", "ScheduledJobFailed", "RebuildDispatchFailed"], + "dedupWindowMs": 300000 +} +``` + +**Node floor (BLOCKER to verify first):** the plugin requires **Node ≥22.12** (its ESM/CJS fix). First implementation step = verify tutorials-ims `engines.node` + CI Node version. If on Node 20, resolve before proceeding — this gates the whole integration. + +`devrel-oncall` is a named channel; the real distribution-list address is bound in the ANS email **action** at provisioning (cockpit / `provision.sh`), not in code/config. + +## 4. Provisioning (MTA) + +`.deploy/mta.yaml` only (root is legacy). **Minor** version bump (feature). + +```yaml +resources: + - name: tutorials-alert-notification + type: org.cloudfoundry.managed-service + parameters: + service: alert-notification + service-plan: standard +# tutorials-srv module requires: tutorials-alert-notification +``` + +- **`srv-qa` cp-list audit:** the new `srv/lib/alerting.js` must be checked against `srv-qa`'s `cp` list. srv-qa doesn't wire alerting, so it should NOT need it — verify the transitive `./` import walk confirms this (missing transitive dep crashes QA boot). +- **MTA provisions the instance, NOT the email action.** The email action + subscription (the `devrel-oncall` address) is a **post-deploy step** (cockpit or generated `provision.sh`). Documented explicitly, not assumed. +- Plugin build-time generation emits into `gen/alerts/` — additive, confirm no interference with the existing MTA build. + +## 5. App wiring + +**Helper — `srv/lib/alerting.js`** (mirrors `metrics.js`: namespace import, never-throws, env kill-switch): + +```js +import cds from '@sap/cds' +const isEnabled = () => process.env.ALERTS_ENABLED === 'true' +let svc +export async function raise(input) { + if (!isEnabled()) return + try { + svc ??= await cds.connect.to('alerts') + await svc.raise(input) + } catch (e) { cds.log('alerting').warn('alert raise failed', e) } +} +``` + +**Three hooks** (each `import * as alerting from './alerting.js'`; alert sits beside the existing signal, never replaces it): + +| Hook | Site | Envelope | +|------|------|----------| +| 1 | `content-publish-session.js:516` (when `outcome==='rejected'`) | `PublishRejected`, ERROR/ALERT, resource `{resourceName:'content-publish', resourceType:'service'}`, body names rejected slugs (`rejectedReverts`) | +| 2 | `scheduler.js:170-174` (`runWithLock` failure) | `ScheduledJobFailed`, ERROR/ALERT, resource `{resourceName: jobName, resourceType:'job'}`, body = errorMessage | +| 3 | `rebuild-trigger.js:182` (catch) | `RebuildDispatchFailed`, ERROR/ALERT, resource `{resourceName:'rebuild-dispatch', resourceType:'service'}`, body = error | + +- **Dedup** (5-min window, keyed `eventType+resourceName`): a flapping job → one email per job per window. +- **Fail-open doubly guaranteed:** plugin never throws + helper try/catch. No hook can break publish/jobs/rebuild. +- **Hook 3 caveat:** `rebuild-trigger.js` uses `console.*` and runs in a debounced `setTimeout` (non-request context). Confirm `cds.connect.to('alerts')` resolves there; if fussy, it logs-and-continues (still fail-open). + +## 6. Testing + +**Unit (`npm test`, in-memory sink via `[test]` profile):** +- `alerting.js`: kill-switch off → no-op; on → routes to service; throwing service swallowed (fail-open). +- Each hook fires the right envelope: forced `outcome==='rejected'` → `PublishRejected`; job throwing in `runWithLock` → `ScheduledJobFailed` w/ `resourceName===jobName`; rebuild catch → `RebuildDispatchFailed`. +- **Guard existing tests:** run the full existing suite; fix any test that exercises these three failure paths and now sees a new signal (per the "service-layer write guard breaks pre-existing tests" gotcha). Update old tests, don't just add new. + +**NOT proven by unit tests (live-verify, operator-owned post-merge):** that a real email lands. Requires instance provisioned + email action bound + real deploy + `ALERTS_ENABLED=true`. This is the plugin's one unverified path (`cds.outboxed()` + real ANS POST). Documented as the acceptance step; NOT done in this PR. + +## 7. Delivery + +- Feature branch off fresh `origin/main` (bg job → isolated worktree). +- `.deploy/mta.yaml` **minor** bump. +- `gh pr create --draft` — never direct-merge to main (even if told to). +- **No deploy from this PR.** Post-merge, operator: verify Node floor, provision instance, bind email action, deploy, flip `ALERTS_ENABLED`, live-verify one alert. + +## 8. Open items to verify during implementation + +- tutorials-ims Node version vs plugin's ≥22.12 floor (BLOCKER — verify first). +- `cds.connect.to('alerts')` resolves in `rebuild-trigger.js`'s `setTimeout` context. +- `srv-qa` cp-list does not need `alerting.js` (transitive import walk). +- Plugin `gen/alerts/` generation doesn't interfere with the MTA build. +- Whether `cds.requires.alerts` `channels`/`routes`/`eventTypes` keys are consumed at runtime by the plugin's client, or only by its build task — confirm against the installed plugin so the config block isn't carrying inert keys at the consumer. diff --git a/package-lock.json b/package-lock.json index 45a8e41ff..a0c8efee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@opentelemetry/exporter-trace-otlp-grpc": "^0.220.0", "@sap-ai-sdk/foundation-models": "^2.12.0", "@sap-ai-sdk/orchestration": "^2.12.0", + "@sap-tutorials/cds-alert-notification": "^1.0.0", "@sap/cds": "^10.0.3", "@sap/xsenv": "^6.2.1", "@sap/xssec": "^4.13.1", @@ -95,7 +96,7 @@ "yauzl": "3.4.0" }, "engines": { - "node": ">=22" + "node": ">=22.12" } }, "app/admin/accomplishments": { @@ -1676,9 +1677,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1695,9 +1693,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1714,9 +1709,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1733,9 +1725,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1752,9 +1741,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1771,9 +1757,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1790,9 +1773,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1809,9 +1789,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1828,9 +1805,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1853,9 +1827,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1878,9 +1849,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1903,9 +1871,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1928,9 +1893,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1953,9 +1915,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1978,9 +1937,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2003,9 +1959,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4412,6 +4365,18 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@sap-tutorials/cds-alert-notification": { + "version": "1.0.0", + "resolved": "https://npm.pkg.github.com/download/@sap-tutorials/cds-alert-notification/1.0.0/4bae16a3ba2982ff88efcb056c57e778883bedde", + "integrity": "sha512-FOQBSaBV+9xxN5ufCT1PVfeReRI6JyGpZP0gpB481Ipo4r14GWaXH2yhSnQfFNOkEt5SPoeTsGfIz5EqSf7NOw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.12" + }, + "peerDependencies": { + "@sap/cds": ">=8" + } + }, "node_modules/@sap-ux/annotation-converter": { "version": "0.10.21", "resolved": "https://registry.npmjs.org/@sap-ux/annotation-converter/-/annotation-converter-0.10.21.tgz", diff --git a/package.json b/package.json index 429248f5a..40dc29e0b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=22" + "node": ">=22.12" }, "scripts": { "start": "cds-serve", @@ -152,6 +152,7 @@ "yauzl": "3.4.0" }, "dependencies": { + "@sap-tutorials/cds-alert-notification": "^1.0.0", "@cap-js-community/websocket": "^1.10.5", "@cap-js/ai": "~1.0.1", "@cap-js/audit-logging": "^1.2.2", @@ -274,6 +275,23 @@ "[production]": { "kind": "telemetry-to-cloud-logging" } + }, + "alerts": { + "impl": "@sap-tutorials/cds-alert-notification", + "kind": "alert-notification-console", + "[test]": { + "kind": "alert-notification-memory" + }, + "[hybrid]": { + "kind": "alert-notification" + }, + "[production]": { + "kind": "alert-notification" + }, + "channels": ["email:devrel-oncall"], + "routes": [{ "minSeverity": "ERROR", "channels": ["email:devrel-oncall"] }], + "eventTypes": ["PublishRejected", "ScheduledJobFailed", "RebuildDispatchFailed"], + "dedupWindowMs": 300000 } }, "hana": { diff --git a/srv/jobs/scheduler.js b/srv/jobs/scheduler.js index ddc765332..09497ef0a 100644 --- a/srv/jobs/scheduler.js +++ b/srv/jobs/scheduler.js @@ -61,6 +61,7 @@ import { sendNotificationEmail, retryFailedEmails } from '../lib/mail-client.js' import { resolveDisplaySettings } from '../lib/runtime-config/display-settings.js'; import { logPipelineStart, logPipelineEnd, logJobItem } from '../lib/pipeline-log.js'; import { deleteStuckOutboxRow } from '../lib/scheduler-wedge.js'; +import * as alerting from '../lib/alerting.js'; import cds from '@sap/cds'; const instanceId = process.env.CF_INSTANCE_INDEX || '0'; @@ -172,6 +173,14 @@ async function runWithLock(jobName, durationMs, fn, opts = {}) { errorMessage = err.message ?? String(err); LOG.error(`Job ${jobName} failed:`, errorMessage); await logPipelineEnd(logId, 'FAILED', jobName, errorMessage); + void alerting.raise({ + eventType: 'ScheduledJobFailed', + severity: 'ERROR', + category: 'ALERT', + subject: `Scheduled job failed: ${jobName}`, + body: String(errorMessage), + resource: { resourceName: jobName, resourceType: 'job' } + }); // fail-open, non-blocking } finally { // #1021: belt-and-suspenders — clear any stuck cds.outbox.Messages // row for this jobName before recording JobLastRun. Runs on every diff --git a/srv/lib/alerting.js b/srv/lib/alerting.js new file mode 100644 index 000000000..ae79a21a4 --- /dev/null +++ b/srv/lib/alerting.js @@ -0,0 +1,28 @@ +// srv/lib/alerting.js +// Fail-open push-alert helper. Mirrors metrics.js: namespace import, never throws, +// env kill-switch. Sits BESIDE existing failure signals (metrics/log/PipelineLog), +// never replaces them. Default OFF (ALERTS_ENABLED !== 'true'). +import cds from '@sap/cds' + +const LOG = cds.log('alerting') +let svcPromise // memoised connection + +function isEnabled () { + return process.env.ALERTS_ENABLED === 'true' +} + +export async function raise (input) { + if (!isEnabled()) return + try { + svcPromise ??= cds.connect.to('alerts') + const svc = await svcPromise + await svc.raise(input) + } catch (e) { + // Never propagate — alerting must not break the path it watches. + svcPromise = undefined // allow a later reconnect attempt + LOG.warn('alert raise failed (swallowed):', e?.message ?? e) + } +} + +// Test-only: reset the memoised connection between cases. +export function _resetForTest () { svcPromise = undefined } diff --git a/srv/lib/content-publish-session.js b/srv/lib/content-publish-session.js index d149a1fe4..96dae2dfa 100644 --- a/srv/lib/content-publish-session.js +++ b/srv/lib/content-publish-session.js @@ -5,6 +5,7 @@ import { acquireLock, releaseLock } from '../jobs/job-lock.js'; import { getNextLegacyId } from './legacy-id.js'; import { toBuffer } from './content-store.js'; import * as metrics from './metrics.js'; +import * as alerting from './alerting.js'; import { recomputeTutorialProgressBulkSQL } from './recompute-tutorial-progress-bulk-sql.js'; import { tutorialsTableInfo } from './_tutorials-table.js'; import { logPipelineStart, logPipelineEnd, logPipelineItem } from './pipeline-log.js'; @@ -514,6 +515,16 @@ export function createSessionHelpers({ namespace }) { metrics.observe('publish.commit.ms', commitMs); metrics.observe('publish.total.ms', totalMs); metrics.counter(outcome === 'rejected' ? 'publish.commit.reject' : 'publish.commit.ok'); + if (outcome === 'rejected') { + void alerting.raise({ + eventType: 'PublishRejected', + severity: 'ERROR', + category: 'ALERT', + subject: `Content publish rejected ${rejectedReverts.length} slug(s)`, + body: `Rejected reverts: ${rejectedReverts.join(', ')}`, + resource: { resourceName: 'content-publish', resourceType: 'service' } + }); // fire-and-forget; helper is fail-open, do NOT await-block the commit path + } } } catch (metricsErr) { LOG.warn(`[content/publish/commit] PublishTimings insert failed (non-fatal): ${metricsErr.message}`); diff --git a/srv/lib/feature-flags/registry.js b/srv/lib/feature-flags/registry.js index c67f875b0..caabc519f 100644 --- a/srv/lib/feature-flags/registry.js +++ b/srv/lib/feature-flags/registry.js @@ -174,6 +174,13 @@ export const FEATURE_FLAGS = [ howToChange: adminTile('joule', '#joule', 'Managed via ChatSettings.a2aEnabled DB column.'), }, // ---- Observability ---- + { + key: 'ALERTS_ENABLED', label: 'ANS push alerting', category: 'Observability', + kind: 'env', envVar: 'ALERTS_ENABLED', envRule: 'true-enables', + valueType: 'boolean', default: false, issue: '', status: 'ga', + description: 'Enables SAP Alert Notification Service push alerts for publish-reject, scheduled-job failures, and rebuild-dispatch failures. Default OFF — enable after deploying the MTA and binding the email action.', + howToChange: cfEnv('ALERTS_ENABLED', 'true'), + }, { key: 'METRICS_ENABLED', label: 'Metrics collection', category: 'Observability', kind: 'env', envVar: 'METRICS_ENABLED', envRule: 'false-disables', diff --git a/srv/lib/rebuild-trigger.js b/srv/lib/rebuild-trigger.js index 50bbe003c..60fa5a8be 100644 --- a/srv/lib/rebuild-trigger.js +++ b/srv/lib/rebuild-trigger.js @@ -26,6 +26,7 @@ import { _primeForTests as _primeResolver, } from './secret-resolver.js'; import { resolveGithubToken, invalidateInstallationToken } from './github-app-token.js'; +import * as alerting from './alerting.js'; const REPO_OWNER = 'sap-tutorials'; const REPO_NAME = 'tutorials-ims'; @@ -180,6 +181,14 @@ export async function scheduleRebuild(reason, opts = {}) { await _state.dispatchFn(inputs, token); } catch (err) { console.error('[rebuild-trigger] dispatch failed:', err.message ?? err); + void alerting.raise({ + eventType: 'RebuildDispatchFailed', + severity: 'ERROR', + category: 'ALERT', + subject: 'Rebuild dispatch failed', + body: String(err?.message ?? err), + resource: { resourceName: 'rebuild-dispatch', resourceType: 'service' }, + }); // Do NOT rethrow. Admin save already succeeded; the next trigger // picks up the missed change. } diff --git a/test/unit/alerting.test.js b/test/unit/alerting.test.js new file mode 100644 index 000000000..bfc5d6598 --- /dev/null +++ b/test/unit/alerting.test.js @@ -0,0 +1,91 @@ +// test/unit/alerting.test.js +import { describe, it, expect, beforeEach, vi } from 'vitest' +import cds from '@sap/cds' + +describe('alerting helper', () => { + beforeEach(() => { vi.resetModules(); delete process.env.ALERTS_ENABLED }) + + it('no-ops when ALERTS_ENABLED is not set (never connects)', async () => { + const spy = vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn() }) + const { raise } = await import('../../srv/lib/alerting.js') + await raise({ eventType: 'X', severity: 'ERROR' }) + // connect.to must not be called when disabled + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('routes to the alerts service when enabled', async () => { + process.env.ALERTS_ENABLED = 'true' + const raiseSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: raiseSpy }) }) + const { raise } = await import('../../srv/lib/alerting.js') + await raise({ eventType: 'PublishRejected', severity: 'ERROR' }) + expect(raiseSpy).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'PublishRejected' })) + }) + + it('never throws when the service.raise throws (fail-open)', async () => { + process.env.ALERTS_ENABLED = 'true' + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: vi.fn().mockRejectedValue(new Error('boom')) }) }) + const { raise } = await import('../../srv/lib/alerting.js') + await expect(raise({ eventType: 'X', severity: 'ERROR' })).resolves.toBeUndefined() + }) + + it('never throws when connect itself throws (fail-open)', async () => { + process.env.ALERTS_ENABLED = 'true' + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockRejectedValue(new Error('no binding')) }) + const { raise } = await import('../../srv/lib/alerting.js') + await expect(raise({ eventType: 'X', severity: 'ERROR' })).resolves.toBeUndefined() + }) + + it('publish-reject envelope shape is correct', async () => { + process.env.ALERTS_ENABLED = 'true' + const raiseSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: raiseSpy }) }) + const { raise } = await import('../../srv/lib/alerting.js') + // Simulate what the hook constructs: + await raise({ + eventType: 'PublishRejected', severity: 'ERROR', category: 'ALERT', + subject: 'Content publish rejected 2 slug(s)', + body: 'Rejected reverts: a, b', + resource: { resourceName: 'content-publish', resourceType: 'service' } + }) + expect(raiseSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'PublishRejected', category: 'ALERT', + resource: { resourceName: 'content-publish', resourceType: 'service' } + })) + }) + + it('scheduled-job-failed envelope uses jobName as resourceName', async () => { + process.env.ALERTS_ENABLED = 'true' + const raiseSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: raiseSpy }) }) + const { raise } = await import('../../srv/lib/alerting.js') + await raise({ + eventType: 'ScheduledJobFailed', severity: 'ERROR', category: 'ALERT', + subject: 'Scheduled job failed: kg-pagerank-job', + body: 'TypeError: boom', + resource: { resourceName: 'kg-pagerank-job', resourceType: 'job' } + }) + expect(raiseSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'ScheduledJobFailed', + resource: { resourceName: 'kg-pagerank-job', resourceType: 'job' } + })) + }) + + it('rebuild-dispatch-failed envelope shape is correct', async () => { + process.env.ALERTS_ENABLED = 'true' + const raiseSpy = vi.fn().mockResolvedValue(undefined) + vi.spyOn(cds, 'connect', 'get').mockReturnValue({ to: vi.fn().mockResolvedValue({ raise: raiseSpy }) }) + const { raise } = await import('../../srv/lib/alerting.js') + await raise({ + eventType: 'RebuildDispatchFailed', severity: 'ERROR', category: 'ALERT', + subject: 'Rebuild dispatch failed', + body: 'fetch failed: 503', + resource: { resourceName: 'rebuild-dispatch', resourceType: 'service' } + }) + expect(raiseSpy).toHaveBeenCalledWith(expect.objectContaining({ + eventType: 'RebuildDispatchFailed', category: 'ALERT', + resource: { resourceName: 'rebuild-dispatch', resourceType: 'service' } + })) + }) +})