Skip to content

Commit d707119

Browse files
alanhwuclaude
andauthored
feat: dashboard vertical change markets, config detection (#481)
* feat: dashboard vertical change markets, config detection * fix: no-shell git markers, bounded dashboard size, non-throwing config detection * fix: widen deploy marker window to 30 commits 10 markers spanned ~10 days, but the dashboard opens at -P3M, so nearly the whole default window had no deploy attribution — the point of the markers. 30 markers now covers 2026-06-11 onward, roughly two months. Each marker costs ~1.1KB across the 11 attribution widgets it is copied into; the synthesized prod body goes 50,674 -> 71,827 bytes against the stack's 90KB synth guard and PutDashboard's 100KB hard limit. Export DEPLOY_MARKER_COUNT and MAX_LABEL_LENGTH so the tests assert against the constants instead of drifting copies (the truncation test had already drifted to 80 vs an actual cap of 50), and derive the size-budget test's worst case from them so raising either has to re-clear the budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: previous endpoints * feat: put deploy markers on the invocations-by-version widget This widget dates each Lambda version's first traffic, and its own docstring says you resolve version -> commit by lining that up against the nearest deploy marker — but it sat in the unmarked ops group, so the comparison meant eyeballing across two widgets with different y-axes. Marking it makes the mapping readable in one place. At width 24 it is also the roomiest home for 31 markers on the dashboard. Position is unchanged (fan-out-by-chain -> this -> quote latency); only the annotations differ. Prod body goes 71,827 -> 74,762 bytes against the stack's 90KB synth guard. Export the widget builder and fold it into the size-budget test's subset, so that budget keeps counting every widget the stack actually annotates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 68a217d commit d707119

9 files changed

Lines changed: 534 additions & 18 deletions

File tree

bin/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export class APIPipeline extends Stack {
4949
const code = CodePipelineSource.connection('Uniswap/uniswapx-parameterization-api', 'main', {
5050
connectionArn:
5151
'arn:aws:codestar-connections:us-east-2:644039819003:connection/4806faf1-c31e-4ea2-a5bf-c6fc1fa79487',
52+
// Full git clone (not a source zip) so the synth step can read git history:
53+
// the dashboard derives its vertical deploy markers from `git log` at synth.
54+
codeBuildCloneOutput: true,
5255
});
5356

5457
const synthStep = new CodeBuildStep('Synth', {

bin/stacks/api-stack.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,11 @@ export class APIStack extends cdk.Stack {
263263
ANALYTICS_STREAM_ARN: firehoseStack.analyticsStreamArn,
264264
},
265265
timeout: Duration.seconds(30),
266+
// NOTE: deliberately no currentVersionOptions.description commit stamping —
267+
// a per-build description forces a new Version + provisioned-concurrency
268+
// re-warm on every merge (incl. dashboard-only ones), causing cold-start
269+
// blips. ExecutedVersion → commit resolves via the dashboard's deploy
270+
// markers (version publish time vs merge time) instead.
266271
});
267272

268273
const quoteLambdaAlias = new aws_lambda.Alias(this, `GetOrdersLiveAlias`, {

bin/stacks/deploy-markers.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { execFileSync } from 'child_process';
2+
3+
export type VerticalAnnotation = {
4+
label: string;
5+
value: string; // ISO 8601 timestamp
6+
};
7+
8+
// Markers are copied into every widget they annotate, so each one costs ~1.1KB of
9+
// dashboard body across the attribution widgets, against a hard 100KB PutDashboard
10+
// limit (the size guard in param-dashboard-stack.ts trips at 90KB). 30 markers puts
11+
// the body around 72KB. Raise these only with a fresh measurement — see the size
12+
// budget test in test/dashboards/deploy-markers.test.ts.
13+
export const MAX_LABEL_LENGTH = 50;
14+
// Sized to span the dashboard's -P3M default window rather than the last few days:
15+
// at this repo's merge cadence 30 markers is roughly a month of history.
16+
export const DEPLOY_MARKER_COUNT = 30;
17+
18+
/**
19+
* Hand-maintained markers for changes that move the latency graphs but leave no
20+
* trace in this repo's git history (config-repo changes, partner-side events).
21+
* Automatic coverage exists for the two common cases — this-repo merges (git-derived
22+
* markers below) and RFQ config edits (the RFQ_CONFIG_CHANGED metric strip) — so
23+
* this list is only for rare milestones worth labeling forever.
24+
*/
25+
export const MILESTONES: VerticalAnnotation[] = [
26+
{ value: '2026-08-07T01:30:00Z', label: 'bulk dead quoter removal (RFQ config)' },
27+
];
28+
29+
/** Parses `git log --format=%H|%cI|%s` output into vertical annotations. */
30+
export function parseGitLog(raw: string): VerticalAnnotation[] {
31+
return raw
32+
.split('\n')
33+
.map((line) => line.trim())
34+
.filter((line) => line.length > 0)
35+
.map((line) => {
36+
const [sha, committedAt, ...subjectParts] = line.split('|');
37+
return {
38+
value: committedAt,
39+
label: `${sha.slice(0, 7)} ${subjectParts.join('|')}`.slice(0, MAX_LABEL_LENGTH),
40+
};
41+
})
42+
.filter((a) => !Number.isNaN(Date.parse(a.value)));
43+
}
44+
45+
/**
46+
* Vertical markers for the last merges to main, read from git at synth time. The
47+
* pipeline's synth step has a full clone (codeBuildCloneOutput in bin/app.ts).
48+
* Marker time is the merge-commit time; the new code serves ~15-30 min later when
49+
* the pipeline finishes — invisible at the dashboard's 3-month default zoom, and
50+
* the invocations-by-version widget gives the exact traffic-shift moment.
51+
*
52+
* execFileSync (not execSync): the format string contains `|`, which a shell would
53+
* parse as a pipeline; execFileSync passes argv directly with no shell involved.
54+
*
55+
* Synth must never fail because history is unavailable (shallow checkout, artifact
56+
* edge cases), so any error degrades to no markers — but LOUDLY, so a regression
57+
* here is visible in the synth log instead of silently shipping a bare dashboard.
58+
*/
59+
export function deployMarkers(): VerticalAnnotation[] {
60+
try {
61+
const raw = execFileSync('git', ['log', '--first-parent', '-n', `${DEPLOY_MARKER_COUNT}`, '--format=%H|%cI|%s'], {
62+
encoding: 'utf8',
63+
stdio: ['ignore', 'pipe', 'pipe'],
64+
});
65+
return parseGitLog(raw);
66+
} catch (e) {
67+
// eslint-disable-next-line no-console
68+
console.warn(`deploy-markers: git history unavailable, dashboard will have no deploy markers: ${e}`);
69+
return [];
70+
}
71+
}

bin/stacks/param-dashboard-stack.ts

Lines changed: 134 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SoftQuoteMetricDimension,
1313
} from '../../lib/entities';
1414
import { ChainId, SUPPORTED_CHAINS } from '../../lib/util/chains';
15+
import { deployMarkers, MILESTONES, VerticalAnnotation } from './deploy-markers';
1516

1617
export type MetricPath =
1718
| string
@@ -49,10 +50,14 @@ export type LambdaWidget = {
4950
};
5051
};
5152
annotations?: {
52-
horizontal: {
53+
horizontal?: {
5354
label?: string;
5455
value: number;
5556
}[];
57+
vertical?: {
58+
label?: string;
59+
value: string; // ISO 8601 timestamp
60+
}[];
5661
};
5762
};
5863
};
@@ -106,6 +111,94 @@ const WASTED_WAIT_COLORS = { p50: '#e34948', p90: '#d03b3b' };
106111
// rollup streams (soft+hard combined) that the shared quote injector emits.
107112
const combined = (metricName: string, opts: Exclude<MetricPath, string>): MetricPath[] => ['Uniswap', metricName, opts];
108113

114+
/**
115+
* Stamps event markers (this-repo deploys from git, plus hand-kept milestones) as
116+
* vertical annotations on every time-series widget, so latency steps line up with
117+
* the change that caused them. Non-graph widgets (tiles, logs) pass through.
118+
*/
119+
export const withEventMarkers = (widgets: LambdaWidget[], markers: VerticalAnnotation[]): LambdaWidget[] =>
120+
markers.length === 0
121+
? widgets
122+
: widgets.map((w) =>
123+
w.properties.view === 'timeSeries'
124+
? {
125+
...w,
126+
properties: {
127+
...w.properties,
128+
annotations: {
129+
...w.properties.annotations,
130+
vertical: [...(w.properties.annotations?.vertical ?? []), ...markers],
131+
},
132+
},
133+
}
134+
: w
135+
);
136+
137+
/**
138+
* Config-repo changes leave no trace in this repo's git history, so they get their
139+
* own marker source: the RFQ_CONFIG_CHANGED metric, emitted by the webhook config
140+
* provider whenever a refresh observes a different filler config. Rendered as a
141+
* thin strip on the same time axis as the story graphs above it — presence of a
142+
* spike means "the config changed here"; its height is just warm-instance count.
143+
*/
144+
const ConfigChangeStripWidget = (region: string): LambdaWidget => ({
145+
height: 3,
146+
width: 24,
147+
type: 'metric',
148+
properties: {
149+
// Two emission streams: quote lambdas publish through the request logger
150+
// (dimensionless rollup), the fade-rate cron through its CircuitBreaker-
151+
// dimensioned logger. Chart both so a change observed only by the cron
152+
// still paints a spike.
153+
metrics: [
154+
combined(Metric.RFQ_CONFIG_CHANGED, { stat: 'Sum', label: 'observed by quote lambdas' }),
155+
[
156+
'Uniswap',
157+
Metric.RFQ_CONFIG_CHANGED,
158+
'Service',
159+
CircuitBreakerMetricDimension.Service,
160+
{ stat: 'Sum', label: 'observed by circuit-breaker cron' },
161+
],
162+
],
163+
view: 'timeSeries',
164+
stacked: false,
165+
region,
166+
period: 300,
167+
title: 'RFQ config changes observed (spike = filler config changed; height is not meaningful)',
168+
},
169+
});
170+
171+
/**
172+
* The exact moment traffic shifted to each Lambda version — the precise complement
173+
* to the git-derived markers, whose timestamps are merge time (~15-30 min before
174+
* serving). Version numbers map to commits by lining a version's first-traffic
175+
* time up with the nearest deploy marker (deliberately no per-version commit
176+
* stamping: that would force a provisioned-concurrency re-warm on every merge),
177+
* which is why this widget carries the markers itself rather than making you read
178+
* the timestamp off a different graph.
179+
*/
180+
export const InvocationsByVersionWidget = (region: string, quoteLambdaFunctionName: string): LambdaWidget => ({
181+
height: 6,
182+
width: 24,
183+
type: 'metric',
184+
properties: {
185+
metrics: [
186+
[
187+
{
188+
expression: `SEARCH('{AWS/Lambda,FunctionName,Resource,ExecutedVersion} FunctionName="${quoteLambdaFunctionName}" MetricName="Invocations"', 'Sum', 300)`,
189+
id: 'invocationsByVersion',
190+
region,
191+
},
192+
],
193+
],
194+
view: 'timeSeries',
195+
stacked: true,
196+
region,
197+
period: 300,
198+
title: 'Soft quote invocations by Lambda version (exact deploy traffic-shift moments)',
199+
},
200+
});
201+
109202
// Per-service metric path builders. Soft and hard are charted separately: hard-quote
110203
// latency includes cosigning and the order post, a structurally different pipeline.
111204
const serviceMetric =
@@ -892,21 +985,30 @@ export class ParamDashboardStack extends cdk.NestedStack {
892985

893986
const region = cdk.Stack.of(this).region;
894987

895-
new aws_cloudwatch.CfnDashboard(this, 'UniswapXParamDashboard', {
896-
dashboardName: `UniswapXParamDashboard`,
897-
dashboardBody: JSON.stringify({
898-
periodOverride: 'inherit',
899-
// Default to a 3-month window: the point of the top rows is the long-run
900-
// downward staircase, not the last hour.
901-
start: '-P3M',
902-
// Widgets auto-flow in array order (no explicit x/y): story row first,
903-
// attribution rows next, the pre-existing ops widgets after.
904-
widgets: [
905-
LatencyStoryRows(region),
906-
PhaseDecompositionWidgets(region),
907-
WastedWaitWidgets(region),
908-
E2EByChainWidgets(region),
909-
FanoutByChainWidgets(region),
988+
// Markers are copied into every widget they annotate; the dashboard body has a
989+
// hard 100KB PutDashboard limit (an unbounded regime measured ~122KB). They
990+
// therefore go ONLY on the attribution graphs — story rows, phase
991+
// decomposition, straggler tax, invocations-by-version — never the ops widgets,
992+
// and both the marker count and label length are capped in deploy-markers.ts.
993+
const eventMarkers = [...MILESTONES, ...deployMarkers()];
994+
995+
const dashboardBody = JSON.stringify({
996+
periodOverride: 'inherit',
997+
// Default to a 3-month window: the point of the top rows is the long-run
998+
// downward staircase, not the last hour.
999+
start: '-P3M',
1000+
// Widgets auto-flow in array order (no explicit x/y): story rows first,
1001+
// attribution rows next, the pre-existing ops widgets after.
1002+
widgets: [
1003+
withEventMarkers(LatencyStoryRows(region), eventMarkers),
1004+
[ConfigChangeStripWidget(region)],
1005+
withEventMarkers([PhaseDecompositionWidgets(region), WastedWaitWidgets(region)].flat(), eventMarkers),
1006+
[E2EByChainWidgets(region), FanoutByChainWidgets(region)].flat(),
1007+
// Marked deliberately, unlike the ops widgets below it: this widget's whole
1008+
// job is to date each version's first traffic, and that timestamp only means
1009+
// something read against the merge that produced the version.
1010+
withEventMarkers([InvocationsByVersionWidget(region, props.quoteLambda.functionName)], eventMarkers),
1011+
[
9101012
LatencyWidget(region),
9111013
RFQLatencyWidget(region),
9121014
QuotesRequestedWidget(region),
@@ -919,7 +1021,22 @@ export class ParamDashboardStack extends cdk.NestedStack {
9191021
FailingRFQLogsWidget(region, props.quoteLambda.logGroup.logGroupName),
9201022
CircuitBreakerWidgets(region),
9211023
].flat(),
922-
}),
1024+
].flat(),
1025+
});
1026+
1027+
// Fail at synth, not at deploy: PutDashboard rejects bodies over 100KB with a
1028+
// stack-update failure. The margin absorbs CDK token expansion (function/log
1029+
// group names serialize as short placeholders here but resolve longer).
1030+
if (dashboardBody.length > 90_000) {
1031+
throw new Error(
1032+
`UniswapXParamDashboard body is ${dashboardBody.length} bytes; PutDashboard rejects >100KB. ` +
1033+
'Trim event markers or widgets (see deploy-markers.ts caps).'
1034+
);
1035+
}
1036+
1037+
new aws_cloudwatch.CfnDashboard(this, 'UniswapXParamDashboard', {
1038+
dashboardName: `UniswapXParamDashboard`,
1039+
dashboardBody,
9231040
});
9241041
}
9251042
}

lib/cron/fade-rate-v2.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
22
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3+
import { setGlobalMetric } from '@uniswap/smart-order-router';
34
import { metricScope, MetricsLogger, Unit } from 'aws-embedded-metrics';
45
import { ScheduledHandler } from 'aws-lambda/trigger/cloudwatch-events';
56
import { EventBridgeEvent } from 'aws-lambda/trigger/eventbridge';
67
import Logger from 'bunyan';
78

89
import { ethers } from 'ethers';
910
import { BETA_S3_KEY, PRODUCTION_S3_KEY, WEBHOOK_CONFIG_BUCKET } from '../constants';
10-
import { CircuitBreakerMetricDimension, Metric, metricContext } from '../entities';
11+
import { AWSMetricsLogger, CircuitBreakerMetricDimension, Metric, metricContext } from '../entities';
1112
import { checkDefined } from '../preconditions/preconditions';
1213
import { S3WebhookConfigurationProvider } from '../providers';
1314
import {
@@ -85,6 +86,14 @@ export const handler: ScheduledHandler = metricScope((metrics) => async (_event:
8586
async function main(metrics: MetricsLogger) {
8687
metrics.setNamespace('Uniswap');
8788
metrics.setDimensions(CircuitBreakerMetricDimension);
89+
// The webhook config provider emits RFQ_CONFIG_CHANGED through the
90+
// smart-order-router module-global metric. The quote lambdas bind it per
91+
// request in their injector; without this binding here, the cron's
92+
// fetchEndpoints() below would observe config changes but publish no
93+
// datapoint. Cron emissions land under Service=CircuitBreaker (this logger's
94+
// dimensions) — the dashboard's config-change strip charts that stream
95+
// alongside the quote lambdas' dimensionless one.
96+
setGlobalMetric(new AWSMetricsLogger(metrics));
8897

8998
const sharedConfig: SharedConfigs = {
9099
Database: checkDefined(process.env.REDSHIFT_DATABASE),

lib/entities/aws-metrics-logger.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ export enum Metric {
8282
// End-to-end handler latency on every response path (200, 404, thrown errors), unlike
8383
// QUOTE_LATENCY which fires only on 200s and is blind to slow 404s.
8484
QUOTE_E2E_LATENCY = 'QUOTE_E2E_LATENCY',
85+
// Emitted when a config refresh observes a different RFQ filler config than the
86+
// previous fetch on the same instance. Marks config-repo changes (filler adds/
87+
// removals) on the latency dashboard, which no git-based deploy marker can see.
88+
// Value is meaningless as a magnitude (each warm instance emits once): presence
89+
// in a period = the config changed in that period.
90+
RFQ_CONFIG_CHANGED = 'RFQ_CONFIG_CHANGED',
8591

8692
// Metrics for circuit breaker
8793
CIRCUIT_BREAKER_V2_CONSECUTIVE_BLOCKS = 'CIRCUIT_BREAKER_V2_CONSECUTIVE_BLOCKS',

0 commit comments

Comments
 (0)