@oxy.so/telemetry 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ Redis, React, React Native or Expo.
11
11
  - `@oxy.so/telemetry/collector` — bounded HTTP/custom-transport flow aggregation.
12
12
  - `@oxy.so/telemetry/server` — pure validation, route bucketing and cardinality
13
13
  keys/interfaces.
14
+ - `@oxy.so/telemetry/metrics` — operational metrics as CloudWatch EMF lines.
14
15
 
15
16
  Browser code must import the browser entry point explicitly. Server code must
16
17
  import the server entry point explicitly; the root entry point has no platform
@@ -136,6 +137,35 @@ explicit infrastructure coordinates) and one of three ways to authenticate: an
136
137
  where the process can attest its workload identity (ADR 0026). Credentials, user
137
138
  metadata and paths never enter public snapshots.
138
139
 
140
+ ## Operational metrics (CloudWatch EMF)
141
+
142
+ ```ts
143
+ import { createEmfPublisher, HTTP_REQUEST_LATENCY, statusClass } from '@oxy.so/telemetry/metrics';
144
+
145
+ const metrics = createEmfPublisher({ namespace: 'Oxy/Api', service: 'oxy-api' });
146
+ metrics.record(HTTP_REQUEST_LATENCY, elapsedMs, {
147
+ Route: '/users/:id', // a log property: free, grouped in Logs Insights
148
+ StatusClass: statusClass(res.statusCode), // a dimension: billed per value
149
+ Caller: 'anonymous',
150
+ });
151
+ // On shutdown:
152
+ metrics.stop();
153
+ ```
154
+
155
+ Observations are aggregated in memory and written once per interval (60 s) as
156
+ one stdout line per (metric, label set). On ECS with the `awslogs` driver,
157
+ CloudWatch Logs extracts the metrics itself: no agent, sidecar or IAM grant.
158
+ A `distribution` keeps a ~5 % bucketed histogram, so CloudWatch answers
159
+ SampleCount (the count) and percentiles for it.
160
+
161
+ Every distinct combination of DIMENSION values is a billed custom metric, so a
162
+ label is a dimension only when the definition lists it in `dimensionSets`, and
163
+ only closed, small sets belong there (status class, a first-party caller).
164
+ Route, method, table and every other label stay plain properties on the line:
165
+ `stats sum(RequestLatency.Count) by Route` in Logs Insights. At most
166
+ `maxSeries` (default 2000) series are held per interval; past that, new series
167
+ fold into one whose labels read `other`.
168
+
139
169
  ## Development
140
170
 
141
171
  ```bash
@@ -0,0 +1,208 @@
1
+ "use strict";
2
+ /**
3
+ * Operational metrics for Oxy services, published as CloudWatch Embedded Metric
4
+ * Format (EMF) log lines.
5
+ *
6
+ * WHY EMF ON STDOUT: every Oxy backend runs on ECS with the `awslogs` driver, and
7
+ * CloudWatch Logs extracts metrics from any log event shaped as EMF — no agent,
8
+ * no sidecar, no scraper, no IAM grant beyond the logging the task already has.
9
+ * A registry that only an HTTP scrape can read (Prometheus) measures into a void
10
+ * when nothing scrapes it, which is how Mention's counters lost every value on
11
+ * every deploy.
12
+ *
13
+ * WHY AGGREGATED: values are folded in memory and flushed once per interval as
14
+ * ONE line per (metric, label set), not one line per observation. A latency is
15
+ * kept as a distribution (`Values`/`Counts`), so CloudWatch still answers
16
+ * SampleCount, p50 and p99 for it — the request COUNT is the distribution's
17
+ * SampleCount, so a service needs no separate counter for it.
18
+ *
19
+ * WHY MOST LABELS ARE NOT DIMENSIONS: every distinct combination of dimension
20
+ * values is a billed custom metric. Route x method as dimensions once measured
21
+ * 405 metrics and ~80 % of the account's CloudWatch bill (Mention #1334). So a
22
+ * label becomes a DIMENSION only when a metric definition names it in
23
+ * `dimensionSets`, and only labels with a small, closed value set belong there.
24
+ * Every other label is written as a plain property of the line: free to store,
25
+ * and grouped in Logs Insights (`stats sum(RequestLatency.Count) by Route`).
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.HTTP_RATE_LIMITED = exports.HTTP_REQUEST_LATENCY = void 0;
29
+ exports.statusClass = statusClass;
30
+ exports.createEmfPublisher = createEmfPublisher;
31
+ /** One HTTP request: SampleCount is the request count, percentiles the latency. */
32
+ exports.HTTP_REQUEST_LATENCY = {
33
+ name: 'RequestLatency',
34
+ unit: 'Milliseconds',
35
+ kind: 'distribution',
36
+ dimensionSets: [['StatusClass'], ['Caller']],
37
+ };
38
+ /** One request a rate limiter refused (429), per calling application. */
39
+ exports.HTTP_RATE_LIMITED = {
40
+ name: 'RateLimited',
41
+ unit: 'Count',
42
+ kind: 'sum',
43
+ dimensionSets: [['Caller']],
44
+ };
45
+ /** `2xx`, `4xx`, … — a closed set, safe as a dimension. */
46
+ function statusClass(status) {
47
+ return Number.isInteger(status) && status >= 100 && status <= 599
48
+ ? `${Math.floor(status / 100)}xx`
49
+ : 'other';
50
+ }
51
+ /** EMF allows at most 100 distinct values per metric on one line. */
52
+ const MAX_DISTRIBUTION_VALUES = 100;
53
+ /** Neighbouring values within ~5 % share a bucket, which bounds the set. */
54
+ const BUCKET_BASE = 1.05;
55
+ const LABEL_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
56
+ const MAX_LABEL_VALUE_LENGTH = 128;
57
+ const RESERVED = new Set(['_aws', 'Service']);
58
+ /** Representative value of a bucket: values within ~5 % collapse together. */
59
+ function bucketOf(value) {
60
+ if (value <= 0)
61
+ return 0;
62
+ const exponent = Math.round(Math.log(value) / Math.log(BUCKET_BASE));
63
+ return Number((BUCKET_BASE ** exponent).toPrecision(3));
64
+ }
65
+ /** Merge adjacent buckets (keeping the larger value) until EMF accepts them. */
66
+ function boundedDistribution(buckets) {
67
+ let entries = [...buckets.entries()].sort(([left], [right]) => left - right);
68
+ while (entries.length > MAX_DISTRIBUTION_VALUES) {
69
+ const merged = [];
70
+ for (let index = 0; index < entries.length; index += 2) {
71
+ const [value, count] = entries[index];
72
+ const next = entries[index + 1];
73
+ merged.push(next ? [next[0], count + next[1]] : [value, count]);
74
+ }
75
+ entries = merged;
76
+ }
77
+ return { values: entries.map(([value]) => value), counts: entries.map(([, count]) => count) };
78
+ }
79
+ function cleanLabels(metric, labels) {
80
+ const clean = {};
81
+ for (const [name, raw] of Object.entries(labels ?? {})) {
82
+ if (!LABEL_NAME.test(name) || RESERVED.has(name) || name === metric.name)
83
+ continue;
84
+ const value = String(raw).slice(0, MAX_LABEL_VALUE_LENGTH);
85
+ clean[name] = value.length > 0 ? value : 'unknown';
86
+ }
87
+ // A dimension named by the definition must be present on every line, or
88
+ // CloudWatch drops the metric from that line.
89
+ for (const set of metric.dimensionSets ?? []) {
90
+ for (const name of set)
91
+ clean[name] ?? (clean[name] = 'unknown');
92
+ }
93
+ return clean;
94
+ }
95
+ function seriesKey(metric, labels) {
96
+ const pairs = Object.keys(labels)
97
+ .sort()
98
+ .map((name) => `${name}=${labels[name]}`);
99
+ return `${metric.name}\u0000${pairs.join('\u0000')}`;
100
+ }
101
+ /** One line on stdout. Reached through `globalThis`: this package carries no Node types. */
102
+ function defaultWrite(line) {
103
+ globalThis.process?.stdout?.write?.(`${line}\n`);
104
+ }
105
+ function createEmfPublisher(options) {
106
+ const flushIntervalMs = options.flushIntervalMs ?? 60000;
107
+ const maxSeries = options.maxSeries ?? 2000;
108
+ const write = options.write ?? defaultWrite;
109
+ const now = options.now ?? Date.now;
110
+ let series = new Map();
111
+ const line = (entry, timestamp) => {
112
+ const { metric } = entry;
113
+ const dimensions = [
114
+ ['Service'],
115
+ ...(metric.dimensionSets ?? []).map((set) => ['Service', ...set]),
116
+ ];
117
+ let value;
118
+ if (metric.kind === 'sum')
119
+ value = entry.sum;
120
+ else if (metric.kind === 'gauge')
121
+ value = entry.last;
122
+ else {
123
+ const { values, counts } = boundedDistribution(entry.buckets);
124
+ value = {
125
+ Values: values,
126
+ Counts: counts,
127
+ Max: entry.max,
128
+ Min: entry.min,
129
+ Count: entry.count,
130
+ Sum: entry.sum,
131
+ };
132
+ }
133
+ return JSON.stringify({
134
+ _aws: {
135
+ Timestamp: timestamp,
136
+ CloudWatchMetrics: [
137
+ {
138
+ Namespace: options.namespace,
139
+ Dimensions: dimensions,
140
+ Metrics: [{ Name: metric.name, Unit: metric.unit }],
141
+ },
142
+ ],
143
+ },
144
+ ...entry.labels,
145
+ Service: options.service,
146
+ [metric.name]: value,
147
+ });
148
+ };
149
+ const flush = () => {
150
+ if (series.size === 0)
151
+ return;
152
+ const current = series;
153
+ series = new Map();
154
+ const timestamp = now();
155
+ for (const entry of current.values()) {
156
+ try {
157
+ write(line(entry, timestamp));
158
+ }
159
+ catch {
160
+ // Metrics are best effort; a failed write must never reach a request.
161
+ }
162
+ }
163
+ };
164
+ const timer = setInterval(flush, flushIntervalMs);
165
+ timer.unref?.();
166
+ return {
167
+ record(metric, value, labels) {
168
+ if (!Number.isFinite(value))
169
+ return;
170
+ if (metric.kind !== 'gauge' && value < 0)
171
+ return;
172
+ let clean = cleanLabels(metric, labels);
173
+ let key = seriesKey(metric, clean);
174
+ if (!series.has(key) && series.size >= maxSeries) {
175
+ clean = Object.fromEntries(Object.keys(clean).map((name) => [name, 'other']));
176
+ key = seriesKey(metric, clean);
177
+ }
178
+ let entry = series.get(key);
179
+ if (!entry) {
180
+ entry = {
181
+ metric,
182
+ labels: clean,
183
+ sum: 0,
184
+ last: 0,
185
+ count: 0,
186
+ min: Number.POSITIVE_INFINITY,
187
+ max: Number.NEGATIVE_INFINITY,
188
+ buckets: new Map(),
189
+ };
190
+ series.set(key, entry);
191
+ }
192
+ entry.sum += value;
193
+ entry.last = value;
194
+ entry.count += 1;
195
+ entry.min = Math.min(entry.min, value);
196
+ entry.max = Math.max(entry.max, value);
197
+ if (metric.kind === 'distribution') {
198
+ const bucket = bucketOf(value);
199
+ entry.buckets.set(bucket, (entry.buckets.get(bucket) ?? 0) + 1);
200
+ }
201
+ },
202
+ flush,
203
+ stop() {
204
+ clearInterval(timer);
205
+ flush();
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Operational metrics for Oxy services, published as CloudWatch Embedded Metric
3
+ * Format (EMF) log lines.
4
+ *
5
+ * WHY EMF ON STDOUT: every Oxy backend runs on ECS with the `awslogs` driver, and
6
+ * CloudWatch Logs extracts metrics from any log event shaped as EMF — no agent,
7
+ * no sidecar, no scraper, no IAM grant beyond the logging the task already has.
8
+ * A registry that only an HTTP scrape can read (Prometheus) measures into a void
9
+ * when nothing scrapes it, which is how Mention's counters lost every value on
10
+ * every deploy.
11
+ *
12
+ * WHY AGGREGATED: values are folded in memory and flushed once per interval as
13
+ * ONE line per (metric, label set), not one line per observation. A latency is
14
+ * kept as a distribution (`Values`/`Counts`), so CloudWatch still answers
15
+ * SampleCount, p50 and p99 for it — the request COUNT is the distribution's
16
+ * SampleCount, so a service needs no separate counter for it.
17
+ *
18
+ * WHY MOST LABELS ARE NOT DIMENSIONS: every distinct combination of dimension
19
+ * values is a billed custom metric. Route x method as dimensions once measured
20
+ * 405 metrics and ~80 % of the account's CloudWatch bill (Mention #1334). So a
21
+ * label becomes a DIMENSION only when a metric definition names it in
22
+ * `dimensionSets`, and only labels with a small, closed value set belong there.
23
+ * Every other label is written as a plain property of the line: free to store,
24
+ * and grouped in Logs Insights (`stats sum(RequestLatency.Count) by Route`).
25
+ */
26
+ /** One HTTP request: SampleCount is the request count, percentiles the latency. */
27
+ export const HTTP_REQUEST_LATENCY = {
28
+ name: 'RequestLatency',
29
+ unit: 'Milliseconds',
30
+ kind: 'distribution',
31
+ dimensionSets: [['StatusClass'], ['Caller']],
32
+ };
33
+ /** One request a rate limiter refused (429), per calling application. */
34
+ export const HTTP_RATE_LIMITED = {
35
+ name: 'RateLimited',
36
+ unit: 'Count',
37
+ kind: 'sum',
38
+ dimensionSets: [['Caller']],
39
+ };
40
+ /** `2xx`, `4xx`, … — a closed set, safe as a dimension. */
41
+ export function statusClass(status) {
42
+ return Number.isInteger(status) && status >= 100 && status <= 599
43
+ ? `${Math.floor(status / 100)}xx`
44
+ : 'other';
45
+ }
46
+ /** EMF allows at most 100 distinct values per metric on one line. */
47
+ const MAX_DISTRIBUTION_VALUES = 100;
48
+ /** Neighbouring values within ~5 % share a bucket, which bounds the set. */
49
+ const BUCKET_BASE = 1.05;
50
+ const LABEL_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
51
+ const MAX_LABEL_VALUE_LENGTH = 128;
52
+ const RESERVED = new Set(['_aws', 'Service']);
53
+ /** Representative value of a bucket: values within ~5 % collapse together. */
54
+ function bucketOf(value) {
55
+ if (value <= 0)
56
+ return 0;
57
+ const exponent = Math.round(Math.log(value) / Math.log(BUCKET_BASE));
58
+ return Number((BUCKET_BASE ** exponent).toPrecision(3));
59
+ }
60
+ /** Merge adjacent buckets (keeping the larger value) until EMF accepts them. */
61
+ function boundedDistribution(buckets) {
62
+ let entries = [...buckets.entries()].sort(([left], [right]) => left - right);
63
+ while (entries.length > MAX_DISTRIBUTION_VALUES) {
64
+ const merged = [];
65
+ for (let index = 0; index < entries.length; index += 2) {
66
+ const [value, count] = entries[index];
67
+ const next = entries[index + 1];
68
+ merged.push(next ? [next[0], count + next[1]] : [value, count]);
69
+ }
70
+ entries = merged;
71
+ }
72
+ return { values: entries.map(([value]) => value), counts: entries.map(([, count]) => count) };
73
+ }
74
+ function cleanLabels(metric, labels) {
75
+ const clean = {};
76
+ for (const [name, raw] of Object.entries(labels ?? {})) {
77
+ if (!LABEL_NAME.test(name) || RESERVED.has(name) || name === metric.name)
78
+ continue;
79
+ const value = String(raw).slice(0, MAX_LABEL_VALUE_LENGTH);
80
+ clean[name] = value.length > 0 ? value : 'unknown';
81
+ }
82
+ // A dimension named by the definition must be present on every line, or
83
+ // CloudWatch drops the metric from that line.
84
+ for (const set of metric.dimensionSets ?? []) {
85
+ for (const name of set)
86
+ clean[name] ?? (clean[name] = 'unknown');
87
+ }
88
+ return clean;
89
+ }
90
+ function seriesKey(metric, labels) {
91
+ const pairs = Object.keys(labels)
92
+ .sort()
93
+ .map((name) => `${name}=${labels[name]}`);
94
+ return `${metric.name}\u0000${pairs.join('\u0000')}`;
95
+ }
96
+ /** One line on stdout. Reached through `globalThis`: this package carries no Node types. */
97
+ function defaultWrite(line) {
98
+ globalThis.process?.stdout?.write?.(`${line}\n`);
99
+ }
100
+ export function createEmfPublisher(options) {
101
+ const flushIntervalMs = options.flushIntervalMs ?? 60000;
102
+ const maxSeries = options.maxSeries ?? 2000;
103
+ const write = options.write ?? defaultWrite;
104
+ const now = options.now ?? Date.now;
105
+ let series = new Map();
106
+ const line = (entry, timestamp) => {
107
+ const { metric } = entry;
108
+ const dimensions = [
109
+ ['Service'],
110
+ ...(metric.dimensionSets ?? []).map((set) => ['Service', ...set]),
111
+ ];
112
+ let value;
113
+ if (metric.kind === 'sum')
114
+ value = entry.sum;
115
+ else if (metric.kind === 'gauge')
116
+ value = entry.last;
117
+ else {
118
+ const { values, counts } = boundedDistribution(entry.buckets);
119
+ value = {
120
+ Values: values,
121
+ Counts: counts,
122
+ Max: entry.max,
123
+ Min: entry.min,
124
+ Count: entry.count,
125
+ Sum: entry.sum,
126
+ };
127
+ }
128
+ return JSON.stringify({
129
+ _aws: {
130
+ Timestamp: timestamp,
131
+ CloudWatchMetrics: [
132
+ {
133
+ Namespace: options.namespace,
134
+ Dimensions: dimensions,
135
+ Metrics: [{ Name: metric.name, Unit: metric.unit }],
136
+ },
137
+ ],
138
+ },
139
+ ...entry.labels,
140
+ Service: options.service,
141
+ [metric.name]: value,
142
+ });
143
+ };
144
+ const flush = () => {
145
+ if (series.size === 0)
146
+ return;
147
+ const current = series;
148
+ series = new Map();
149
+ const timestamp = now();
150
+ for (const entry of current.values()) {
151
+ try {
152
+ write(line(entry, timestamp));
153
+ }
154
+ catch {
155
+ // Metrics are best effort; a failed write must never reach a request.
156
+ }
157
+ }
158
+ };
159
+ const timer = setInterval(flush, flushIntervalMs);
160
+ timer.unref?.();
161
+ return {
162
+ record(metric, value, labels) {
163
+ if (!Number.isFinite(value))
164
+ return;
165
+ if (metric.kind !== 'gauge' && value < 0)
166
+ return;
167
+ let clean = cleanLabels(metric, labels);
168
+ let key = seriesKey(metric, clean);
169
+ if (!series.has(key) && series.size >= maxSeries) {
170
+ clean = Object.fromEntries(Object.keys(clean).map((name) => [name, 'other']));
171
+ key = seriesKey(metric, clean);
172
+ }
173
+ let entry = series.get(key);
174
+ if (!entry) {
175
+ entry = {
176
+ metric,
177
+ labels: clean,
178
+ sum: 0,
179
+ last: 0,
180
+ count: 0,
181
+ min: Number.POSITIVE_INFINITY,
182
+ max: Number.NEGATIVE_INFINITY,
183
+ buckets: new Map(),
184
+ };
185
+ series.set(key, entry);
186
+ }
187
+ entry.sum += value;
188
+ entry.last = value;
189
+ entry.count += 1;
190
+ entry.min = Math.min(entry.min, value);
191
+ entry.max = Math.max(entry.max, value);
192
+ if (metric.kind === 'distribution') {
193
+ const bucket = bucketOf(value);
194
+ entry.buckets.set(bucket, (entry.buckets.get(bucket) ?? 0) + 1);
195
+ }
196
+ },
197
+ flush,
198
+ stop() {
199
+ clearInterval(timer);
200
+ flush();
201
+ },
202
+ };
203
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Operational metrics for Oxy services, published as CloudWatch Embedded Metric
3
+ * Format (EMF) log lines.
4
+ *
5
+ * WHY EMF ON STDOUT: every Oxy backend runs on ECS with the `awslogs` driver, and
6
+ * CloudWatch Logs extracts metrics from any log event shaped as EMF — no agent,
7
+ * no sidecar, no scraper, no IAM grant beyond the logging the task already has.
8
+ * A registry that only an HTTP scrape can read (Prometheus) measures into a void
9
+ * when nothing scrapes it, which is how Mention's counters lost every value on
10
+ * every deploy.
11
+ *
12
+ * WHY AGGREGATED: values are folded in memory and flushed once per interval as
13
+ * ONE line per (metric, label set), not one line per observation. A latency is
14
+ * kept as a distribution (`Values`/`Counts`), so CloudWatch still answers
15
+ * SampleCount, p50 and p99 for it — the request COUNT is the distribution's
16
+ * SampleCount, so a service needs no separate counter for it.
17
+ *
18
+ * WHY MOST LABELS ARE NOT DIMENSIONS: every distinct combination of dimension
19
+ * values is a billed custom metric. Route x method as dimensions once measured
20
+ * 405 metrics and ~80 % of the account's CloudWatch bill (Mention #1334). So a
21
+ * label becomes a DIMENSION only when a metric definition names it in
22
+ * `dimensionSets`, and only labels with a small, closed value set belong there.
23
+ * Every other label is written as a plain property of the line: free to store,
24
+ * and grouped in Logs Insights (`stats sum(RequestLatency.Count) by Route`).
25
+ */
26
+ export type MetricUnit = 'Count' | 'Milliseconds' | 'Seconds' | 'Bytes' | 'Percent' | 'Count/Second' | 'None';
27
+ /**
28
+ * `sum` adds every value in the interval (a counter), `distribution` keeps the
29
+ * values for percentiles (a latency or a size), and `gauge` keeps the last one.
30
+ */
31
+ export type MetricKind = 'sum' | 'distribution' | 'gauge';
32
+ export interface MetricDefinition {
33
+ /** CloudWatch metric name; also the field holding its value on the line. */
34
+ readonly name: string;
35
+ readonly unit: MetricUnit;
36
+ readonly kind: MetricKind;
37
+ /**
38
+ * Label names promoted to CloudWatch dimensions. Each inner list is one
39
+ * dimension set, published IN ADDITION to the always-present `[Service]`
40
+ * set. Only closed, small value sets belong here: each distinct combination
41
+ * is a billed custom metric.
42
+ */
43
+ readonly dimensionSets?: readonly (readonly string[])[];
44
+ }
45
+ export type MetricLabels = Readonly<Record<string, string>>;
46
+ export interface EmfPublisherOptions {
47
+ /** CloudWatch namespace, e.g. `Oxy/Api`. */
48
+ readonly namespace: string;
49
+ /** Value of the `Service` dimension every metric carries. */
50
+ readonly service: string;
51
+ /** Flush period. Default 60 s, CloudWatch's standard resolution. */
52
+ readonly flushIntervalMs?: number;
53
+ /**
54
+ * Distinct (metric, label set) series held per interval. A label value that
55
+ * traffic can mint must not be able to grow memory or log volume without
56
+ * bound, so past this many every NEW series is folded into one whose labels
57
+ * all read `other`. Default 2000.
58
+ */
59
+ readonly maxSeries?: number;
60
+ /** Where each line goes. Default: one line on stdout. */
61
+ readonly write?: (line: string) => void;
62
+ /** Clock, for tests. */
63
+ readonly now?: () => number;
64
+ }
65
+ export interface EmfPublisher {
66
+ /** Fold one observation into the current interval. Never throws. */
67
+ record(metric: MetricDefinition, value: number, labels?: MetricLabels): void;
68
+ /** Write the current interval now and start a new one. */
69
+ flush(): void;
70
+ /** Flush and stop the timer, for graceful shutdown. */
71
+ stop(): void;
72
+ }
73
+ /** One HTTP request: SampleCount is the request count, percentiles the latency. */
74
+ export declare const HTTP_REQUEST_LATENCY: MetricDefinition;
75
+ /** One request a rate limiter refused (429), per calling application. */
76
+ export declare const HTTP_RATE_LIMITED: MetricDefinition;
77
+ /** `2xx`, `4xx`, … — a closed set, safe as a dimension. */
78
+ export declare function statusClass(status: number): string;
79
+ export declare function createEmfPublisher(options: EmfPublisherOptions): EmfPublisher;
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oxy.so/telemetry",
3
- "version": "2.0.0",
4
- "description": "Framework-agnostic anonymous activity telemetry primitives for Oxy services.",
3
+ "version": "2.1.0",
4
+ "description": "Framework-agnostic anonymous activity telemetry and CloudWatch EMF metrics primitives for Oxy services.",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
7
7
  "module": "dist/esm/index.js",
@@ -19,6 +19,9 @@
19
19
  ],
20
20
  "socket": [
21
21
  "dist/types/socket.d.ts"
22
+ ],
23
+ "metrics": [
24
+ "dist/types/metrics.d.ts"
22
25
  ]
23
26
  }
24
27
  },
@@ -88,6 +91,16 @@
88
91
  "types": "./dist/types/socket.d.ts",
89
92
  "default": "./dist/cjs/socket.js"
90
93
  }
94
+ },
95
+ "./metrics": {
96
+ "import": {
97
+ "types": "./dist/types/metrics.d.ts",
98
+ "default": "./dist/esm/metrics.js"
99
+ },
100
+ "require": {
101
+ "types": "./dist/types/metrics.d.ts",
102
+ "default": "./dist/cjs/metrics.js"
103
+ }
91
104
  }
92
105
  },
93
106
  "files": [
@@ -109,7 +122,7 @@
109
122
  "build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types",
110
123
  "build:cjs": "tsc -p tsconfig.cjs.json && node scripts/mark-cjs.mjs",
111
124
  "build:esm": "tsc -p tsconfig.esm.json",
112
- "build:types": "tsc -p tsconfig.types.json",
125
+ "build:types": "tsc -p tsconfig.types.json && node scripts/mark-types-cjs.mjs",
113
126
  "clean": "rm -rf dist",
114
127
  "typescript": "tsc --noEmit",
115
128
  "test": "jest",
@@ -0,0 +1,45 @@
1
+ import { createEmfPublisher, HTTP_REQUEST_LATENCY, HTTP_RATE_LIMITED } from '../metrics';
2
+
3
+ describe('EMF publisher', () => {
4
+ it('folds an interval into one EMF line per series with bounded dimensions', () => {
5
+ const lines: string[] = [];
6
+ const publisher = createEmfPublisher({
7
+ namespace: 'Oxy/Test',
8
+ service: 'svc',
9
+ write: (line) => lines.push(line),
10
+ now: () => 1_000,
11
+ maxSeries: 2,
12
+ });
13
+ const labels = { Route: '/users/:id', StatusClass: '2xx', Caller: 'anonymous' };
14
+ for (const ms of [10, 10, 200]) publisher.record(HTTP_REQUEST_LATENCY, ms, labels);
15
+ publisher.record(HTTP_RATE_LIMITED, 1, { Caller: 'service:app' });
16
+ // A third series exceeds maxSeries and folds into `other`.
17
+ publisher.record(HTTP_RATE_LIMITED, 1, { Caller: 'service:another' });
18
+ publisher.stop();
19
+
20
+ const [latency, limited, overflow] = lines.map((line) => JSON.parse(line));
21
+ expect(latency._aws).toEqual({
22
+ Timestamp: 1_000,
23
+ CloudWatchMetrics: [
24
+ {
25
+ Namespace: 'Oxy/Test',
26
+ Dimensions: [['Service'], ['Service', 'StatusClass'], ['Service', 'Caller']],
27
+ Metrics: [{ Name: 'RequestLatency', Unit: 'Milliseconds' }],
28
+ },
29
+ ],
30
+ });
31
+ expect(latency).toMatchObject({ Service: 'svc', Route: '/users/:id', StatusClass: '2xx' });
32
+ // Values are ~5 % buckets; Count/Sum/Min/Max stay exact.
33
+ expect(latency.RequestLatency.Values).toEqual([9.91, 204]);
34
+ expect(latency.RequestLatency).toMatchObject({
35
+ Counts: [2, 1],
36
+ Max: 200,
37
+ Min: 10,
38
+ Count: 3,
39
+ Sum: 220,
40
+ });
41
+ expect(limited).toMatchObject({ Caller: 'service:app', RateLimited: 1 });
42
+ expect(overflow).toMatchObject({ Caller: 'other', RateLimited: 1 });
43
+ expect(lines).toHaveLength(3);
44
+ });
45
+ });
package/src/metrics.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Operational metrics for Oxy services, published as CloudWatch Embedded Metric
3
+ * Format (EMF) log lines.
4
+ *
5
+ * WHY EMF ON STDOUT: every Oxy backend runs on ECS with the `awslogs` driver, and
6
+ * CloudWatch Logs extracts metrics from any log event shaped as EMF — no agent,
7
+ * no sidecar, no scraper, no IAM grant beyond the logging the task already has.
8
+ * A registry that only an HTTP scrape can read (Prometheus) measures into a void
9
+ * when nothing scrapes it, which is how Mention's counters lost every value on
10
+ * every deploy.
11
+ *
12
+ * WHY AGGREGATED: values are folded in memory and flushed once per interval as
13
+ * ONE line per (metric, label set), not one line per observation. A latency is
14
+ * kept as a distribution (`Values`/`Counts`), so CloudWatch still answers
15
+ * SampleCount, p50 and p99 for it — the request COUNT is the distribution's
16
+ * SampleCount, so a service needs no separate counter for it.
17
+ *
18
+ * WHY MOST LABELS ARE NOT DIMENSIONS: every distinct combination of dimension
19
+ * values is a billed custom metric. Route x method as dimensions once measured
20
+ * 405 metrics and ~80 % of the account's CloudWatch bill (Mention #1334). So a
21
+ * label becomes a DIMENSION only when a metric definition names it in
22
+ * `dimensionSets`, and only labels with a small, closed value set belong there.
23
+ * Every other label is written as a plain property of the line: free to store,
24
+ * and grouped in Logs Insights (`stats sum(RequestLatency.Count) by Route`).
25
+ */
26
+
27
+ export type MetricUnit =
28
+ | 'Count'
29
+ | 'Milliseconds'
30
+ | 'Seconds'
31
+ | 'Bytes'
32
+ | 'Percent'
33
+ | 'Count/Second'
34
+ | 'None';
35
+
36
+ /**
37
+ * `sum` adds every value in the interval (a counter), `distribution` keeps the
38
+ * values for percentiles (a latency or a size), and `gauge` keeps the last one.
39
+ */
40
+ export type MetricKind = 'sum' | 'distribution' | 'gauge';
41
+
42
+ export interface MetricDefinition {
43
+ /** CloudWatch metric name; also the field holding its value on the line. */
44
+ readonly name: string;
45
+ readonly unit: MetricUnit;
46
+ readonly kind: MetricKind;
47
+ /**
48
+ * Label names promoted to CloudWatch dimensions. Each inner list is one
49
+ * dimension set, published IN ADDITION to the always-present `[Service]`
50
+ * set. Only closed, small value sets belong here: each distinct combination
51
+ * is a billed custom metric.
52
+ */
53
+ readonly dimensionSets?: readonly (readonly string[])[];
54
+ }
55
+
56
+ export type MetricLabels = Readonly<Record<string, string>>;
57
+
58
+ export interface EmfPublisherOptions {
59
+ /** CloudWatch namespace, e.g. `Oxy/Api`. */
60
+ readonly namespace: string;
61
+ /** Value of the `Service` dimension every metric carries. */
62
+ readonly service: string;
63
+ /** Flush period. Default 60 s, CloudWatch's standard resolution. */
64
+ readonly flushIntervalMs?: number;
65
+ /**
66
+ * Distinct (metric, label set) series held per interval. A label value that
67
+ * traffic can mint must not be able to grow memory or log volume without
68
+ * bound, so past this many every NEW series is folded into one whose labels
69
+ * all read `other`. Default 2000.
70
+ */
71
+ readonly maxSeries?: number;
72
+ /** Where each line goes. Default: one line on stdout. */
73
+ readonly write?: (line: string) => void;
74
+ /** Clock, for tests. */
75
+ readonly now?: () => number;
76
+ }
77
+
78
+ export interface EmfPublisher {
79
+ /** Fold one observation into the current interval. Never throws. */
80
+ record(metric: MetricDefinition, value: number, labels?: MetricLabels): void;
81
+ /** Write the current interval now and start a new one. */
82
+ flush(): void;
83
+ /** Flush and stop the timer, for graceful shutdown. */
84
+ stop(): void;
85
+ }
86
+
87
+ /** One HTTP request: SampleCount is the request count, percentiles the latency. */
88
+ export const HTTP_REQUEST_LATENCY: MetricDefinition = {
89
+ name: 'RequestLatency',
90
+ unit: 'Milliseconds',
91
+ kind: 'distribution',
92
+ dimensionSets: [['StatusClass'], ['Caller']],
93
+ };
94
+
95
+ /** One request a rate limiter refused (429), per calling application. */
96
+ export const HTTP_RATE_LIMITED: MetricDefinition = {
97
+ name: 'RateLimited',
98
+ unit: 'Count',
99
+ kind: 'sum',
100
+ dimensionSets: [['Caller']],
101
+ };
102
+
103
+ /** `2xx`, `4xx`, … — a closed set, safe as a dimension. */
104
+ export function statusClass(status: number): string {
105
+ return Number.isInteger(status) && status >= 100 && status <= 599
106
+ ? `${Math.floor(status / 100)}xx`
107
+ : 'other';
108
+ }
109
+
110
+ /** EMF allows at most 100 distinct values per metric on one line. */
111
+ const MAX_DISTRIBUTION_VALUES = 100;
112
+ /** Neighbouring values within ~5 % share a bucket, which bounds the set. */
113
+ const BUCKET_BASE = 1.05;
114
+ const LABEL_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
115
+ const MAX_LABEL_VALUE_LENGTH = 128;
116
+ const RESERVED = new Set(['_aws', 'Service']);
117
+
118
+ interface Series {
119
+ metric: MetricDefinition;
120
+ labels: Record<string, string>;
121
+ sum: number;
122
+ last: number;
123
+ count: number;
124
+ min: number;
125
+ max: number;
126
+ buckets: Map<number, number>;
127
+ }
128
+
129
+ /** Representative value of a bucket: values within ~5 % collapse together. */
130
+ function bucketOf(value: number): number {
131
+ if (value <= 0) return 0;
132
+ const exponent = Math.round(Math.log(value) / Math.log(BUCKET_BASE));
133
+ return Number((BUCKET_BASE ** exponent).toPrecision(3));
134
+ }
135
+
136
+ /** Merge adjacent buckets (keeping the larger value) until EMF accepts them. */
137
+ function boundedDistribution(buckets: Map<number, number>): { values: number[]; counts: number[] } {
138
+ let entries = [...buckets.entries()].sort(([left], [right]) => left - right);
139
+ while (entries.length > MAX_DISTRIBUTION_VALUES) {
140
+ const merged: [number, number][] = [];
141
+ for (let index = 0; index < entries.length; index += 2) {
142
+ const [value, count] = entries[index];
143
+ const next = entries[index + 1];
144
+ merged.push(next ? [next[0], count + next[1]] : [value, count]);
145
+ }
146
+ entries = merged;
147
+ }
148
+ return { values: entries.map(([value]) => value), counts: entries.map(([, count]) => count) };
149
+ }
150
+
151
+ function cleanLabels(
152
+ metric: MetricDefinition,
153
+ labels: MetricLabels | undefined,
154
+ ): Record<string, string> {
155
+ const clean: Record<string, string> = {};
156
+ for (const [name, raw] of Object.entries(labels ?? {})) {
157
+ if (!LABEL_NAME.test(name) || RESERVED.has(name) || name === metric.name) continue;
158
+ const value = String(raw).slice(0, MAX_LABEL_VALUE_LENGTH);
159
+ clean[name] = value.length > 0 ? value : 'unknown';
160
+ }
161
+ // A dimension named by the definition must be present on every line, or
162
+ // CloudWatch drops the metric from that line.
163
+ for (const set of metric.dimensionSets ?? []) {
164
+ for (const name of set) clean[name] ??= 'unknown';
165
+ }
166
+ return clean;
167
+ }
168
+
169
+ function seriesKey(metric: MetricDefinition, labels: Record<string, string>): string {
170
+ const pairs = Object.keys(labels)
171
+ .sort()
172
+ .map((name) => `${name}=${labels[name]}`);
173
+ return `${metric.name}\u0000${pairs.join('\u0000')}`;
174
+ }
175
+
176
+ interface StdoutHost {
177
+ process?: { stdout?: { write?: (chunk: string) => unknown } };
178
+ }
179
+
180
+ /** One line on stdout. Reached through `globalThis`: this package carries no Node types. */
181
+ function defaultWrite(line: string): void {
182
+ (globalThis as StdoutHost).process?.stdout?.write?.(`${line}\n`);
183
+ }
184
+
185
+ export function createEmfPublisher(options: EmfPublisherOptions): EmfPublisher {
186
+ const flushIntervalMs = options.flushIntervalMs ?? 60_000;
187
+ const maxSeries = options.maxSeries ?? 2_000;
188
+ const write = options.write ?? defaultWrite;
189
+ const now = options.now ?? Date.now;
190
+ let series = new Map<string, Series>();
191
+
192
+ const line = (entry: Series, timestamp: number): string => {
193
+ const { metric } = entry;
194
+ const dimensions = [
195
+ ['Service'],
196
+ ...(metric.dimensionSets ?? []).map((set) => ['Service', ...set]),
197
+ ];
198
+ let value: unknown;
199
+ if (metric.kind === 'sum') value = entry.sum;
200
+ else if (metric.kind === 'gauge') value = entry.last;
201
+ else {
202
+ const { values, counts } = boundedDistribution(entry.buckets);
203
+ value = {
204
+ Values: values,
205
+ Counts: counts,
206
+ Max: entry.max,
207
+ Min: entry.min,
208
+ Count: entry.count,
209
+ Sum: entry.sum,
210
+ };
211
+ }
212
+ return JSON.stringify({
213
+ _aws: {
214
+ Timestamp: timestamp,
215
+ CloudWatchMetrics: [
216
+ {
217
+ Namespace: options.namespace,
218
+ Dimensions: dimensions,
219
+ Metrics: [{ Name: metric.name, Unit: metric.unit }],
220
+ },
221
+ ],
222
+ },
223
+ ...entry.labels,
224
+ Service: options.service,
225
+ [metric.name]: value,
226
+ });
227
+ };
228
+
229
+ const flush = (): void => {
230
+ if (series.size === 0) return;
231
+ const current = series;
232
+ series = new Map();
233
+ const timestamp = now();
234
+ for (const entry of current.values()) {
235
+ try {
236
+ write(line(entry, timestamp));
237
+ } catch {
238
+ // Metrics are best effort; a failed write must never reach a request.
239
+ }
240
+ }
241
+ };
242
+
243
+ const timer = setInterval(flush, flushIntervalMs);
244
+ (timer as { unref?: () => void }).unref?.();
245
+
246
+ return {
247
+ record(metric, value, labels) {
248
+ if (!Number.isFinite(value)) return;
249
+ if (metric.kind !== 'gauge' && value < 0) return;
250
+ let clean = cleanLabels(metric, labels);
251
+ let key = seriesKey(metric, clean);
252
+ if (!series.has(key) && series.size >= maxSeries) {
253
+ clean = Object.fromEntries(Object.keys(clean).map((name) => [name, 'other']));
254
+ key = seriesKey(metric, clean);
255
+ }
256
+ let entry = series.get(key);
257
+ if (!entry) {
258
+ entry = {
259
+ metric,
260
+ labels: clean,
261
+ sum: 0,
262
+ last: 0,
263
+ count: 0,
264
+ min: Number.POSITIVE_INFINITY,
265
+ max: Number.NEGATIVE_INFINITY,
266
+ buckets: new Map(),
267
+ };
268
+ series.set(key, entry);
269
+ }
270
+ entry.sum += value;
271
+ entry.last = value;
272
+ entry.count += 1;
273
+ entry.min = Math.min(entry.min, value);
274
+ entry.max = Math.max(entry.max, value);
275
+ if (metric.kind === 'distribution') {
276
+ const bucket = bucketOf(value);
277
+ entry.buckets.set(bucket, (entry.buckets.get(bucket) ?? 0) + 1);
278
+ }
279
+ },
280
+ flush,
281
+ stop() {
282
+ clearInterval(timer);
283
+ flush();
284
+ },
285
+ };
286
+ }