Skip to content

Commit 4606c73

Browse files
abhishekgarg18Abhishek Gargclaude
authored
feat(aso-overlay): CloudWatch EMF instrumentation for READ path (SITES-48140) (#2822)
## Summary Instruments `GET /config/:service/redirects.txt` and the ASO overlay auth handler with CloudWatch Embedded Metric Format (EMF) via stdout envelopes under the new `Mysticat/AsoOverlay` namespace. No IAM change; no PutMetricData calls; reuses the existing `metrics-emf.js` pattern already proven on `Mysticat/GitHubService` (webhooks) and `Mysticat/Brands`. Closes the observability gap Cornel called out: *"aso needs to have its own monitoring/observability in place — each service needs to have its own dashboards, dispatcher will not monitor for broken API keys"*. Tracked in [SITES-48140](https://jira.corp.adobe.com/browse/SITES-48140) (linked to [SITES-44966](https://jira.corp.adobe.com/browse/SITES-44966)). Paired PR on `spacecat-infrastructure`: adobe/spacecat-infrastructure#675 — Grafana + Splunk dashboards visualizing these metrics. ## Metrics emitted Frozen catalog in `src/support/aso-overlay-metrics.js`: | Metric | Unit | Dimensions | Purpose | |--------|------|-----------|---------| | `AsoOverlayRequestTotal` | Count | Environment, Outcome, Tier | Volume + outcome split | | `AsoOverlayRequestDurationMs` | Milliseconds | Environment, Outcome | End-to-end latency | | `AsoOverlayEtagPresent` | Count | Environment | ETag emission health | | `AsoOverlayConditionalGet304` | Count | Environment | Cache-hit rate | | `AsoOverlayIfNoneMatchInvalid` | Count | Environment, Reason=`unquoted` | RFC 7232 sidecar bug signal | | `AsoOverlayS3ReadDurationMs` | Milliseconds | Environment, Outcome | S3 backend health | | `AsoOverlayAuthKeyUsed` | Count | Environment, Slot=`current\|previous` | **Rotation-in-flight signal** | | `AsoOverlayAuthFailed` | Count | Environment, Reason | Defense-in-depth (most fail at Fastly edge) | ## Design notes **Cardinality:** No per-tenant dimensions (10k+ services would blow the budget). Tenant-level views come from Fastly access logs in Splunk. **Rotation observability (crown jewel):** `AsoOverlayAuthKeyUsed{Slot=previous}` non-zero signals rotation in-flight; sustained non-zero across > 24h signals the sidecar fleet hasn't picked up the new key. **INM validity:** `AsoOverlayIfNoneMatchInvalid{Reason=unquoted}` spikes indicate specific sidecar rollouts parsing ETags wrong (RFC 7232 §2.3 requires quoted opaque-tag). Empty/whitespace-only INM is normalized to `null` by `getHeader()` so is not tracked separately. **Auth handler scope:** `AsoOverlayKeyHandler` runs on every request. Metrics are emitted **only** for the overlay route to avoid flooding the namespace. **Fault tolerance:** All metric emission is wrapped in try/catch inside `emitMetric()` — a stdout write failure never breaks the request path. ## Complementary follow-ups (tracked on SITES-48140) - **Fastly VCL enhancement** — emit key-slot-matched + 401-sub-reason as log fields for the edge-decided requests that never reach origin. Needs platform team owner (Alina/Cornel) — ASO team lacks Fastly rights. - **`mystique` #3381** — `aso_overlay_fastly_purge_*` Prometheus metrics on WRITE path. - **Grafana alert rules** — rotation-slot=previous > 24h, 401 spike, 304 rate collapse, S3 AccessDenied spike. ## Test plan - [x] 14 controller-level metric tests + 7 auth-handler metric tests (all new) - [x] 100% coverage on new files - [x] Full suite: 14968 passing, 0 failing - [x] ESLint clean; type-check passes - [x] Existing 53-test redirects suite still green (instrumentation is additive) - [ ] Post-deploy on dev: verify EMF envelopes land in CloudWatch under `Mysticat/AsoOverlay` with correct dimensions - [ ] Wire Grafana dashboard (paired PR) against dev metrics before promoting to stage 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Abhishek Garg <abhigarg+adobe@adobe.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5aa68e2 commit 4606c73

5 files changed

Lines changed: 718 additions & 10 deletions

File tree

src/controllers/redirects.js

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-acc
2020
import { isNonEmptyObject } from '@adobe/spacecat-shared-utils';
2121

2222
import { getHeader } from '../support/http-headers.js';
23+
import { emitMetric, resolveEnvironment } from '../support/metrics-emf.js';
24+
import {
25+
ASO_OVERLAY_NAMESPACE, OUTCOME, S3_RESULT, INM_INVALID_REASON,
26+
} from '../support/aso-overlay-metrics.js';
2327

2428
// Cloud Manager service identifier, e.g. cm-p154709-e1629980. Digit runs are
2529
// capped to a realistic length to avoid arbitrarily long lookups; the capture
@@ -134,13 +138,44 @@ function RedirectsController(ctx) {
134138
*/
135139
async function getRedirects(context) {
136140
const { service } = context.params;
141+
// Capture start time up front so every terminal branch reports duration.
142+
const startedAt = Date.now();
143+
const emitOpts = {
144+
environment: resolveEnvironment(env),
145+
namespace: ASO_OVERLAY_NAMESPACE,
146+
};
147+
148+
// Single call-site helper: emits RequestTotal + RequestDurationMs together
149+
// so on-call always has both a rate and a latency view for the same outcome.
150+
// Note: no `Tier` dimension — the origin route is `/config/:service/...`
151+
// (Fastly strips the public `/config/<tier>/...` prefix before proxying), so
152+
// any tier extraction from the origin path would be either wrong or
153+
// per-tenant, blowing up CloudWatch cardinality. Environment (auto-added by
154+
// emitMetric from AWS_ENV) already carries dev-vs-prod.
155+
const emitFinal = (outcome) => {
156+
emitMetric(
157+
{ name: 'AsoOverlayRequestTotal', dimensions: { Outcome: outcome } },
158+
emitOpts,
159+
);
160+
emitMetric(
161+
{
162+
name: 'AsoOverlayRequestDurationMs',
163+
value: Date.now() - startedAt,
164+
unit: 'Milliseconds',
165+
dimensions: { Outcome: outcome },
166+
},
167+
emitOpts,
168+
);
169+
};
137170

138171
const match = SERVICE_RE.exec(service);
139172
if (!match) {
173+
emitFinal(OUTCOME.BAD_REQUEST_400);
140174
return badRequest('Invalid service identifier', NO_STORE_HEADERS);
141175
}
142176
if (!bucketName) {
143177
log.error('[aso-overlay] S3_ASO_OVERLAYS_BUCKET is not configured');
178+
emitFinal(OUTCOME.BUCKET_NOT_CONFIGURED);
144179
return internalServerError('Overlay endpoint not configured', NO_STORE_HEADERS);
145180
}
146181

@@ -155,6 +190,7 @@ function RedirectsController(ctx) {
155190
);
156191
if (!site) {
157192
log.info('[aso-overlay] no site resolves for service', { service });
193+
emitFinal(OUTCOME.AUTHZ_NO_SITE);
158194
return notFound('No redirect overlay found', NO_STORE_HEADERS);
159195
}
160196

@@ -166,20 +202,36 @@ function RedirectsController(ctx) {
166202
);
167203
if (!entitlement) {
168204
log.info('[aso-overlay] site org not ASO-entitled', { siteId: site.getId() });
205+
emitFinal(OUTCOME.AUTHZ_NO_ENTITLEMENT);
169206
return notFound('No redirect overlay found', NO_STORE_HEADERS);
170207
}
171208
const enrollments = await SiteEnrollment.allBySiteId(site.getId());
172209
const enrolled = enrollments.some((se) => se.getEntitlementId() === entitlement.getId());
173210
if (!enrolled) {
174211
log.info('[aso-overlay] site not enrolled for ASO', { siteId: site.getId() });
212+
emitFinal(OUTCOME.AUTHZ_NOT_ENROLLED);
175213
return notFound('No redirect overlay found', NO_STORE_HEADERS);
176214
}
177215

178216
// Read the overlay with the Lambda's own execution role (no SigV4 from caller).
179217
const key = `config/${service}/redirects.txt`;
218+
const s3StartedAt = Date.now();
219+
// Emits AsoOverlayS3ReadDurationMs with the S3-scoped result. Kept separate
220+
// from `emitFinal`'s request-level Outcome so dashboards don't ambiguously
221+
// slice S3 latency by request-level codes (e.g. "S3 success on a 304").
222+
const emitS3Duration = (s3Result) => emitMetric(
223+
{
224+
name: 'AsoOverlayS3ReadDurationMs',
225+
value: Date.now() - s3StartedAt,
226+
unit: 'Milliseconds',
227+
dimensions: { S3Result: s3Result },
228+
},
229+
emitOpts,
230+
);
180231
try {
181232
const command = new GetObjectCommand({ Bucket: bucketName, Key: key });
182233
const response = await s3Client.send(command);
234+
emitS3Duration(S3_RESULT.SUCCESS);
183235

184236
// S3 returns the object's ETag already quoted (RFC 7232 opaque-tag form),
185237
// e.g. `"d41d8cd98f00b204e9800998ecf8427e"`. Passthrough gives the client
@@ -191,12 +243,26 @@ function RedirectsController(ctx) {
191243
// a plain 200 rather than breaking.
192244
const etag = response.ETag;
193245
const ifNoneMatch = getHeader(context, 'if-none-match');
194-
if (ifNoneMatchMatches(ifNoneMatch, etag)) {
195-
// Deliberately no per-request 304 log — this is the *expected* path at
196-
// steady state and would dominate log volume across the dispatcher fleet.
197-
// If 304-rate visibility becomes necessary, emit a metric rather than a
198-
// log line.
199-
//
246+
const inmMatched = ifNoneMatchMatches(ifNoneMatch, etag);
247+
// Track shell-stripped / malformed If-None-Match separately: a spike here
248+
// means a specific sidecar rollout parses ETags wrong (RFC 7232 §2.3
249+
// requires quoted opaque-tag; unquoted → rejected → falls through to 200).
250+
if (ifNoneMatch !== null && !inmMatched) {
251+
const trimmed = ifNoneMatch.trim();
252+
// Every token failed the opaque-tag check — likely unquoted. Non-matching
253+
// but well-formed validators (client's stale copy) are normal cache
254+
// misses and don't warrant a metric.
255+
if (trimmed !== '*'
256+
&& !trimmed.split(',').some((tok) => normalizeValidator(tok) !== null)) {
257+
emitMetric(
258+
{ name: 'AsoOverlayIfNoneMatchInvalid', dimensions: { Reason: INM_INVALID_REASON.UNQUOTED } },
259+
emitOpts,
260+
);
261+
}
262+
}
263+
if (inmMatched) {
264+
emitMetric({ name: 'AsoOverlayConditionalGet304' }, emitOpts);
265+
emitFinal(OUTCOME.NOT_MODIFIED_304);
200266
// 304 MUST NOT include a message body (RFC 7230 §3.3.3); MUST carry any
201267
// Cache-Control / ETag we would have sent on a 200 (RFC 7232 §4.1).
202268
// Content-type is set explicitly because createResponse would otherwise
@@ -215,6 +281,10 @@ function RedirectsController(ctx) {
215281
}
216282

217283
const body = await response.Body.transformToString();
284+
if (etag) {
285+
emitMetric({ name: 'AsoOverlayEtagPresent' }, emitOpts);
286+
}
287+
emitFinal(OUTCOME.OK_200);
218288
return createResponse(body, 200, {
219289
'content-type': 'text/plain; charset=utf-8',
220290
// Cache-friendly for Fastly request-collapsing; edge TTL also set in VCL.
@@ -231,6 +301,8 @@ function RedirectsController(ctx) {
231301
} catch (err) {
232302
const code = err.$metadata?.httpStatusCode;
233303
if (err.name === 'NoSuchKey' || code === 404) {
304+
emitS3Duration(S3_RESULT.NO_SUCH_KEY);
305+
emitFinal(OUTCOME.S3_NO_SUCH_KEY);
234306
return notFound('No redirect overlay found', NO_STORE_HEADERS);
235307
}
236308
// The reader role intentionally lacks s3:ListBucket (least privilege, no key
@@ -245,9 +317,13 @@ function RedirectsController(ctx) {
245317
+ '— missing object or missing s3:GetObject grant',
246318
err,
247319
);
320+
emitS3Duration(S3_RESULT.ACCESS_DENIED);
321+
emitFinal(OUTCOME.S3_ACCESS_DENIED);
248322
return notFound('No redirect overlay found', NO_STORE_HEADERS);
249323
}
250324
log.error(`[aso-overlay] failed to read ${key} from ${bucketName}`, err);
325+
emitS3Duration(S3_RESULT.UNEXPECTED);
326+
emitFinal(OUTCOME.S3_UNEXPECTED);
251327
return internalServerError('Failed to retrieve redirect overlay', NO_STORE_HEADERS);
252328
}
253329
}

src/support/aso-overlay-key-handler.js

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
import crypto from 'crypto';
1414
import AbstractHandler from '@adobe/spacecat-shared-http-utils/src/auth/handlers/abstract.js';
1515
import AuthInfo from '@adobe/spacecat-shared-http-utils/src/auth/auth-info.js';
16+
import { emitMetric, resolveEnvironment } from './metrics-emf.js';
17+
import {
18+
ASO_OVERLAY_NAMESPACE,
19+
AUTH_FAIL_REASON,
20+
AUTH_KEY_SLOT,
21+
} from './aso-overlay-metrics.js';
1622

1723
// Path-scoped to the ASO redirect-overlay read route. The service shape mirrors
1824
// the controller's own validation (RedirectsController SERVICE_RE), so only
@@ -61,28 +67,59 @@ class AsoOverlayKeyHandler extends AbstractHandler {
6167
const suffix = context.pathInfo?.suffix || '';
6268

6369
// Path-scoped: only the overlay read route. Return null otherwise so the
64-
// remaining handlers run.
70+
// remaining handlers run. No metric emitted for non-overlay paths — this
71+
// handler runs on every request and we'd flood the namespace.
6572
if (method !== 'GET' || !OVERLAY_ROUTE.test(suffix)) {
6673
return null;
6774
}
6875

76+
// From here on, we're on the overlay route — every outcome is worth a metric.
77+
const emitOpts = {
78+
environment: resolveEnvironment(context.env),
79+
namespace: ASO_OVERLAY_NAMESPACE,
80+
};
81+
6982
const providedKey = request.headers.get('x-aso-api-key');
7083
// No key supplied: not an overlay-key request. Fall through (→ 401 if no
71-
// other handler authenticates).
84+
// other handler authenticates). Track separately from an invalid key.
7285
if (!providedKey) {
86+
emitMetric(
87+
{ name: 'AsoOverlayAuthFailed', dimensions: { Reason: AUTH_FAIL_REASON.MISSING } },
88+
emitOpts,
89+
);
7390
return null;
7491
}
7592

7693
const expectedKey = context.env?.ASO_OVERLAY_API_KEY;
7794
if (!expectedKey) {
7895
this.log('ASO_OVERLAY_API_KEY is not configured', 'error');
96+
emitMetric(
97+
{ name: 'AsoOverlayAuthFailed', dimensions: { Reason: AUTH_FAIL_REASON.CONFIG_MISSING } },
98+
emitOpts,
99+
);
79100
return null;
80101
}
81102

82103
const previousKey = context.env?.ASO_OVERLAY_API_KEY_PREVIOUS;
83-
if (!safeEqual(providedKey, expectedKey)
84-
&& !(previousKey && safeEqual(providedKey, previousKey))) {
104+
// Track which slot matched — `previous` non-zero signals rotation in-flight,
105+
// and sustained non-zero across > 24h signals the sidecar fleet hasn't picked
106+
// up the new key.
107+
if (safeEqual(providedKey, expectedKey)) {
108+
emitMetric(
109+
{ name: 'AsoOverlayAuthKeyUsed', dimensions: { Slot: AUTH_KEY_SLOT.CURRENT } },
110+
emitOpts,
111+
);
112+
} else if (previousKey && safeEqual(providedKey, previousKey)) {
113+
emitMetric(
114+
{ name: 'AsoOverlayAuthKeyUsed', dimensions: { Slot: AUTH_KEY_SLOT.PREVIOUS } },
115+
emitOpts,
116+
);
117+
} else {
85118
this.log('invalid X-ASO-API-Key', 'warn');
119+
emitMetric(
120+
{ name: 'AsoOverlayAuthFailed', dimensions: { Reason: AUTH_FAIL_REASON.INVALID } },
121+
emitOpts,
122+
);
86123
return null;
87124
}
88125

src/support/aso-overlay-metrics.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
/**
14+
* Canonical catalog of CloudWatch EMF metrics emitted by the ASO dispatcher
15+
* overlay read path (`GET /config/:service/redirects.txt`) and its auth handler.
16+
* Drift guard: `RedirectsController` and `AsoOverlayKeyHandler` must reference
17+
* exactly these names, and the Grafana dashboard queries against them via the
18+
* `Mysticat/AsoOverlay` CloudWatch namespace.
19+
*
20+
* Cardinality note: no per-tenant dimensions here. CloudWatch charges per unique
21+
* dimension combination; 10k+ CM services would blow the budget. Tenant-level
22+
* views come from Fastly access logs in Splunk instead.
23+
*/
24+
25+
export const ASO_OVERLAY_NAMESPACE = 'Mysticat/AsoOverlay';
26+
27+
export const ASO_OVERLAY_METRICS = Object.freeze([
28+
'AsoOverlayRequestTotal', // Count · dims: Environment, Outcome
29+
'AsoOverlayRequestDurationMs', // Milliseconds · dims: Environment, Outcome
30+
'AsoOverlayEtagPresent', // Count · dims: Environment (subset of 200)
31+
'AsoOverlayConditionalGet304', // Count · dims: Environment (INM matched)
32+
'AsoOverlayIfNoneMatchInvalid', // Count · dims: Environment, Reason
33+
'AsoOverlayS3ReadDurationMs', // Milliseconds · dims: Environment, S3Result
34+
'AsoOverlayAuthKeyUsed', // Count · dims: Environment, Slot (rotation-in-flight signal)
35+
'AsoOverlayAuthFailed', // Count · dims: Environment, Reason
36+
]);
37+
38+
// Outcome enum for AsoOverlayRequestTotal / AsoOverlayRequestDurationMs.
39+
// Distinguishes 404 sub-reasons so on-call can tell authz-fail from
40+
// S3-object-missing (indistinguishable to clients by design).
41+
export const OUTCOME = Object.freeze({
42+
OK_200: '200',
43+
NOT_MODIFIED_304: '304',
44+
BAD_REQUEST_400: '400',
45+
AUTHZ_NO_SITE: '404-authz-nosite',
46+
AUTHZ_NO_ENTITLEMENT: '404-authz-noent',
47+
AUTHZ_NOT_ENROLLED: '404-authz-noenroll',
48+
S3_NO_SUCH_KEY: '404-s3-nosuchkey',
49+
S3_ACCESS_DENIED: '404-s3-accessdenied',
50+
BUCKET_NOT_CONFIGURED: '500-config',
51+
S3_UNEXPECTED: '500-s3',
52+
});
53+
54+
// S3Result enum for AsoOverlayS3ReadDurationMs. Kept separate from OUTCOME so
55+
// dashboards querying "filter by Outcome" don't ambiguously match S3-scoped
56+
// metrics — the two share the axis name only, not the semantic domain.
57+
export const S3_RESULT = Object.freeze({
58+
SUCCESS: 'success',
59+
NO_SUCH_KEY: 'nosuchkey',
60+
ACCESS_DENIED: 'accessdenied',
61+
UNEXPECTED: 'unexpected',
62+
});
63+
64+
// Reason enum for AsoOverlayAuthFailed.
65+
export const AUTH_FAIL_REASON = Object.freeze({
66+
MISSING: 'missing', // Overlay route, X-ASO-API-Key header absent
67+
INVALID: 'invalid', // Header present but doesn't match either key slot
68+
CONFIG_MISSING: 'config-missing', // Server-side ASO_OVERLAY_API_KEY env var unset (deploy misconfig)
69+
});
70+
71+
// Reason enum for AsoOverlayIfNoneMatchInvalid. Whitespace-only values are
72+
// normalized to null by `getHeader` and reach the controller as "absent" — so
73+
// we don't track them separately.
74+
export const INM_INVALID_REASON = Object.freeze({
75+
UNQUOTED: 'unquoted', // Client sent bare token without RFC 7232 §2.3 quotes
76+
});
77+
78+
// Slot enum for AsoOverlayAuthKeyUsed. `previous` non-zero signals rotation
79+
// in-flight; sustained non-zero across > 24h signals rotation didn't complete.
80+
export const AUTH_KEY_SLOT = Object.freeze({
81+
CURRENT: 'current',
82+
PREVIOUS: 'previous',
83+
});

0 commit comments

Comments
 (0)