blogwright-analytics 0.3.3
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 +162 -0
- package/dist/adapters/duckdb-ingest.d.ts +76 -0
- package/dist/adapters/duckdb-ingest.js +173 -0
- package/dist/adapters/duckdb-query.d.ts +56 -0
- package/dist/adapters/duckdb-query.js +80 -0
- package/dist/adapters/duckdb-session.d.ts +168 -0
- package/dist/adapters/duckdb-session.js +330 -0
- package/dist/app/_app/immutable/assets/0.BTQrrh5B.css +1 -0
- package/dist/app/_app/immutable/assets/2.CZSK3rT8.css +1 -0
- package/dist/app/_app/immutable/assets/BrushContext.D7c8UPey.css +1 -0
- package/dist/app/_app/immutable/assets/ChartAnnotations.CPxIG7Mw.css +1 -0
- package/dist/app/_app/immutable/assets/Circle.C5MKzgk2.css +1 -0
- package/dist/app/_app/immutable/assets/DefaultTooltip.C5-uctZ7.css +1 -0
- package/dist/app/_app/immutable/assets/Group.DV48xipa.css +1 -0
- package/dist/app/_app/immutable/assets/Labels.BxZ4NUVz.css +1 -0
- package/dist/app/_app/immutable/assets/Legend.CxnrE4Ye.css +1 -0
- package/dist/app/_app/immutable/assets/Line.fkmsECm9.css +1 -0
- package/dist/app/_app/immutable/assets/Path.CvpwNZ6g.css +1 -0
- package/dist/app/_app/immutable/assets/Rect.CtRaGMmQ.css +1 -0
- package/dist/app/_app/immutable/assets/Text.j9l35qB0.css +1 -0
- package/dist/app/_app/immutable/assets/TransformContext.Bs_HkpAk.css +1 -0
- package/dist/app/_app/immutable/assets/Voronoi.ce7atosu.css +1 -0
- package/dist/app/_app/immutable/chunks/-aNGNaBT.js +1 -0
- package/dist/app/_app/immutable/chunks/6djn-yLs.js +1 -0
- package/dist/app/_app/immutable/chunks/B1amyutE.js +1 -0
- package/dist/app/_app/immutable/chunks/B3vZDoek.js +1 -0
- package/dist/app/_app/immutable/chunks/B5KRA4hC.js +1 -0
- package/dist/app/_app/immutable/chunks/BClnVG6H.js +1 -0
- package/dist/app/_app/immutable/chunks/BID1NNRh.js +1 -0
- package/dist/app/_app/immutable/chunks/BR2LaRms.js +1 -0
- package/dist/app/_app/immutable/chunks/Bd1gDe3Y.js +1 -0
- package/dist/app/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/app/_app/immutable/chunks/Bl052uUt.js +1 -0
- package/dist/app/_app/immutable/chunks/Bye3lL0c.js +1 -0
- package/dist/app/_app/immutable/chunks/C58PZtCD.js +4 -0
- package/dist/app/_app/immutable/chunks/CAzydqEO.js +1 -0
- package/dist/app/_app/immutable/chunks/CCch3uox.js +1 -0
- package/dist/app/_app/immutable/chunks/CIlSMUH9.js +1 -0
- package/dist/app/_app/immutable/chunks/CO1vUXfR.js +1 -0
- package/dist/app/_app/immutable/chunks/CPbD8C65.js +5 -0
- package/dist/app/_app/immutable/chunks/CRTcXoMo.js +1 -0
- package/dist/app/_app/immutable/chunks/CjjyIQAO.js +1 -0
- package/dist/app/_app/immutable/chunks/CuXAxjvF.js +1 -0
- package/dist/app/_app/immutable/chunks/CvyVA_jC.js +1 -0
- package/dist/app/_app/immutable/chunks/CxGCFVdy.js +1 -0
- package/dist/app/_app/immutable/chunks/D0Ty6LN0.js +1 -0
- package/dist/app/_app/immutable/chunks/D2AaQUUW.js +1 -0
- package/dist/app/_app/immutable/chunks/D2BnX0Uk.js +3 -0
- package/dist/app/_app/immutable/chunks/DJc8C0NK.js +1 -0
- package/dist/app/_app/immutable/chunks/DKMlMI4a.js +1 -0
- package/dist/app/_app/immutable/chunks/DVXZkpbf.js +1 -0
- package/dist/app/_app/immutable/chunks/DVt8ukQ_.js +1 -0
- package/dist/app/_app/immutable/chunks/DZPlYdq_.js +1 -0
- package/dist/app/_app/immutable/chunks/Db0q5_zr.js +1 -0
- package/dist/app/_app/immutable/chunks/Dfvzj6n2.js +1 -0
- package/dist/app/_app/immutable/chunks/Dh958be7.js +1 -0
- package/dist/app/_app/immutable/chunks/DjKLLdnY.js +15 -0
- package/dist/app/_app/immutable/chunks/Doz7YX1W.js +1 -0
- package/dist/app/_app/immutable/chunks/DthYhn6Y.js +2 -0
- package/dist/app/_app/immutable/chunks/DtuTIrAM.js +1 -0
- package/dist/app/_app/immutable/chunks/HclGiUj8.js +1 -0
- package/dist/app/_app/immutable/chunks/Hx0TNsV3.js +1 -0
- package/dist/app/_app/immutable/chunks/RobXhXPM.js +1 -0
- package/dist/app/_app/immutable/chunks/V9ZjaxiY.js +1 -0
- package/dist/app/_app/immutable/chunks/Y5urAfNy.js +1 -0
- package/dist/app/_app/immutable/chunks/caXkbKD3.js +1 -0
- package/dist/app/_app/immutable/chunks/devYm2ud.js +1 -0
- package/dist/app/_app/immutable/chunks/mtZWP0zR.js +1 -0
- package/dist/app/_app/immutable/chunks/vDgBJUjM.js +1 -0
- package/dist/app/_app/immutable/chunks/xIq_fFFM.js +1 -0
- package/dist/app/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/app/_app/immutable/chunks/z05MoCFz.js +1 -0
- package/dist/app/_app/immutable/entry/app.CLAerUAN.js +2 -0
- package/dist/app/_app/immutable/entry/start.D3MqnNci.js +1 -0
- package/dist/app/_app/immutable/nodes/0.UTMEigHJ.js +1 -0
- package/dist/app/_app/immutable/nodes/1.Cn4f11bT.js +1 -0
- package/dist/app/_app/immutable/nodes/2.B39cIcr2.js +6 -0
- package/dist/app/_app/version.json +1 -0
- package/dist/app/index.html +82 -0
- package/dist/aws/clients.d.ts +70 -0
- package/dist/aws/clients.js +52 -0
- package/dist/aws/errors.d.ts +41 -0
- package/dist/aws/errors.js +70 -0
- package/dist/aws/firehose.d.ts +228 -0
- package/dist/aws/firehose.js +347 -0
- package/dist/aws/glue.d.ts +103 -0
- package/dist/aws/glue.js +225 -0
- package/dist/aws/lambda.d.ts +132 -0
- package/dist/aws/lambda.js +339 -0
- package/dist/aws/s3tables.d.ts +120 -0
- package/dist/aws/s3tables.js +281 -0
- package/dist/backfill.d.ts +100 -0
- package/dist/backfill.js +294 -0
- package/dist/commands.d.ts +124 -0
- package/dist/commands.js +336 -0
- package/dist/config.d.ts +162 -0
- package/dist/config.js +317 -0
- package/dist/fixture-ingest.d.ts +49 -0
- package/dist/fixture-ingest.js +43 -0
- package/dist/fixture-query.d.ts +39 -0
- package/dist/fixture-query.js +70 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +35 -0
- package/dist/nodes.d.ts +404 -0
- package/dist/nodes.js +2708 -0
- package/dist/paths.d.ts +45 -0
- package/dist/paths.js +47 -0
- package/dist/plugin.d.ts +102 -0
- package/dist/plugin.js +248 -0
- package/dist/ports.d.ts +113 -0
- package/dist/ports.js +35 -0
- package/dist/queries.d.ts +301 -0
- package/dist/queries.js +414 -0
- package/dist/schema.d.ts +240 -0
- package/dist/schema.js +154 -0
- package/dist/server.d.ts +150 -0
- package/dist/server.js +499 -0
- package/dist/transform/bots.d.ts +47 -0
- package/dist/transform/bots.js +73 -0
- package/dist/transform/handler.d.ts +135 -0
- package/dist/transform/handler.js +177 -0
- package/dist/transform/map-record.d.ts +110 -0
- package/dist/transform/map-record.js +275 -0
- package/dist/transform/visitor-key.d.ts +83 -0
- package/dist/transform/visitor-key.js +120 -0
- package/dist/transform-bundle/index.mjs +21456 -0
- package/dist/transform-bundle/transform-manifest.json +4 -0
- package/dist/transform-hash.d.ts +135 -0
- package/dist/transform-hash.js +186 -0
- package/dist/write-transform-manifest.mjs +365 -0
- package/package.json +59 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Firehose transform Lambda's envelope: the batch handler that wraps
|
|
3
|
+
* `mapRecord`. This is step 5 of
|
|
4
|
+
* [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md) -
|
|
5
|
+
* "drops records the schema cannot accept, emitting them to the Firehose error
|
|
6
|
+
* prefix rather than failing the batch" - plus the cold-start secret read step
|
|
7
|
+
* 3 calls for.
|
|
8
|
+
*
|
|
9
|
+
* It owns four things and nothing else: the envelope types, the base64 and
|
|
10
|
+
* JSON boundary, the cold-start read of the long-lived salt secret, and the
|
|
11
|
+
* translation of one `MapRecordResult` into one Firehose response entry. Every
|
|
12
|
+
* decision about *what a row is* - which field fills which column, which
|
|
13
|
+
* values are unusable, how `visitor_key` is derived and under which day's salt
|
|
14
|
+
* - belongs to `map-record.ts` and is forwarded here, never re-decided.
|
|
15
|
+
*
|
|
16
|
+
* ## The two outcomes, and why they are different
|
|
17
|
+
*
|
|
18
|
+
* Firehose's per-record vocabulary distinguishes them and so does this module:
|
|
19
|
+
*
|
|
20
|
+
* - **`Ok`** carries the mapped row, re-encoded. Firehose writes it.
|
|
21
|
+
* - **`ProcessingFailed`** carries no data. Firehose writes the *original*
|
|
22
|
+
* record to the error prefix and delivers the rest of the batch. That is the
|
|
23
|
+
* visible data-quality signal a record the schema cannot accept should
|
|
24
|
+
* produce.
|
|
25
|
+
*
|
|
26
|
+
* Firehose's third result, `Dropped`, is deliberately unused: it discards the
|
|
27
|
+
* record silently, which is the failure mode this whole pipeline is written
|
|
28
|
+
* against. A record that cannot be mapped must end up somewhere an operator
|
|
29
|
+
* can read it.
|
|
30
|
+
*
|
|
31
|
+
* `recordId` is echoed verbatim onto every entry, `Ok` and `ProcessingFailed`
|
|
32
|
+
* alike. Firehose discards a response whose ids do not match the request's -
|
|
33
|
+
* not the mismatched entry, the response - so an id dropped from a failed
|
|
34
|
+
* entry loses the whole batch.
|
|
35
|
+
*
|
|
36
|
+
* ## Why there is no try/catch around the mapping
|
|
37
|
+
*
|
|
38
|
+
* `dailySalt` and `visitorKey` throw on an empty secret, day, IP or salt
|
|
39
|
+
* (`visitor-key.ts`), because an unsalted digest that looks protected is worse
|
|
40
|
+
* than a failed batch: the table stores `user_agent` in the clear beside
|
|
41
|
+
* `visitor_key`, and an unsalted SHA-256 of an IPv4 address is a lookup table
|
|
42
|
+
* over a 2^32 space. A blanket catch around `mapRecord` would convert those
|
|
43
|
+
* throws into `ProcessingFailed` for every record - the whole batch to the
|
|
44
|
+
* error prefix, no error anywhere, an empty dashboard, and nothing to say why.
|
|
45
|
+
* So the only `try` in this module is the one around `JSON.parse`, which is
|
|
46
|
+
* this module's own boundary. A throw from the mapping propagates, the
|
|
47
|
+
* invocation fails, and Firehose retries the batch and raises its own error
|
|
48
|
+
* metric. Loudly is the point.
|
|
49
|
+
*
|
|
50
|
+
* The same reasoning governs the secret: a failed or empty read fails the
|
|
51
|
+
* batch. There is no unsalted or date-only fallback, because a fallback would
|
|
52
|
+
* write unprotected data that looks protected, and no reprocessing could
|
|
53
|
+
* repair rows already written under it.
|
|
54
|
+
*
|
|
55
|
+
* ## No AWS SDK, no network, no clock
|
|
56
|
+
*
|
|
57
|
+
* The envelope types below are repo-owned declarations, not `@types/aws-lambda`
|
|
58
|
+
* - the package takes no dependency for a shape this small and this stable.
|
|
59
|
+
* The one thing this module cannot do without is the secret, and it reaches it
|
|
60
|
+
* through {@link SaltSecretStore}, a structural slice of core's own client
|
|
61
|
+
* (the `packages/pds/src/secret.ts` precedent). That import is type-only, so it
|
|
62
|
+
* erases at compile: this module carries no client, no signer and no transport,
|
|
63
|
+
* and its tests stub the store with a plain object rather than mocking a module
|
|
64
|
+
* or reaching a cloud.
|
|
65
|
+
*
|
|
66
|
+
* Which also means this module is not a composition root. Binding a real
|
|
67
|
+
* `SecretsManagerClient` - built over the us-east-1 signer, since the function
|
|
68
|
+
* and its secret both live there - is the bundle entry's job, and it is the
|
|
69
|
+
* caller that hands the constructed handler to Lambda.
|
|
70
|
+
*/
|
|
71
|
+
import type { SecretsManagerClient } from 'blogwright-core';
|
|
72
|
+
/**
|
|
73
|
+
* One record as Firehose sends it. The event carries more - `invocationId`,
|
|
74
|
+
* `deliveryStreamArn`, `region`, and per-record source metadata - and this
|
|
75
|
+
* handler reads none of it, so none of it is declared: a field spelled here
|
|
76
|
+
* and never read is a field a later reader would believe is load-bearing.
|
|
77
|
+
*/
|
|
78
|
+
interface FirehoseTransformRecord {
|
|
79
|
+
/** Firehose's id for this record. Echoed onto the response entry unchanged. */
|
|
80
|
+
readonly recordId: string;
|
|
81
|
+
/** The record's payload, base64-encoded. */
|
|
82
|
+
readonly data: string;
|
|
83
|
+
}
|
|
84
|
+
/** The transform invocation's payload: one buffer of records to translate. */
|
|
85
|
+
export interface FirehoseTransformRequest {
|
|
86
|
+
readonly records: readonly FirehoseTransformRecord[];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* What Firehose does with one record. `Dropped` exists in the API and is not
|
|
90
|
+
* offered here - see the module comment.
|
|
91
|
+
*/
|
|
92
|
+
type FirehoseRecordResult = 'Ok' | 'ProcessingFailed';
|
|
93
|
+
/** One record's outcome, in Firehose's vocabulary. */
|
|
94
|
+
interface FirehoseTransformedRecord {
|
|
95
|
+
/** The request entry's id, unchanged. */
|
|
96
|
+
readonly recordId: string;
|
|
97
|
+
readonly result: FirehoseRecordResult;
|
|
98
|
+
/** The transformed payload, base64-encoded. Present only for `Ok`. */
|
|
99
|
+
readonly data?: string | undefined;
|
|
100
|
+
}
|
|
101
|
+
/** The transform's response: one entry per request record, in request order. */
|
|
102
|
+
export interface FirehoseTransformResponse {
|
|
103
|
+
readonly records: readonly FirehoseTransformedRecord[];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The read this handler needs, as a structural slice of core's client rather
|
|
107
|
+
* than a hand-rolled function type, so the seam is checked against the real
|
|
108
|
+
* `SecretsManagerClient` and a test satisfies it with a plain object.
|
|
109
|
+
*/
|
|
110
|
+
export type SaltSecretStore = Pick<SecretsManagerClient, 'getSecretValue'>;
|
|
111
|
+
/**
|
|
112
|
+
* The environment variable naming the Secrets Manager secret behind
|
|
113
|
+
* `visitor_key`, set on the function by the `analytics-transform-function`
|
|
114
|
+
* node (task 50). The *name* travels in the environment; the value never does,
|
|
115
|
+
* so it cannot be read off the function's configuration.
|
|
116
|
+
*/
|
|
117
|
+
export declare const SALT_SECRET_NAME_ENV = "ANALYTICS_SALT_SECRET_NAME";
|
|
118
|
+
/** The environment as the runtime hands it over. */
|
|
119
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
120
|
+
/**
|
|
121
|
+
* Build the Lambda entry point over a Secrets Manager read.
|
|
122
|
+
*
|
|
123
|
+
* The secret's name is resolved now, so a function deployed without
|
|
124
|
+
* {@link SALT_SECRET_NAME_ENV} fails at initialisation rather than once per
|
|
125
|
+
* batch. Its *value* is read on the first invocation and cached for the life
|
|
126
|
+
* of the execution environment: a 60-second Firehose buffer is roughly 43,000
|
|
127
|
+
* invocations a month, so reading per invocation would spend more than half
|
|
128
|
+
* the price of the secret itself again on `GetSecretValue` calls, for nothing.
|
|
129
|
+
*
|
|
130
|
+
* A failed read is not cached. Caching the failure would let one throttled
|
|
131
|
+
* call disable an execution environment for its whole life, failing every
|
|
132
|
+
* batch it ever sees; leaving it uncached means the next invocation retries.
|
|
133
|
+
*/
|
|
134
|
+
export declare function createTransformHandler(secrets: SaltSecretStore, env?: Environment): (request: FirehoseTransformRequest) => Promise<FirehoseTransformResponse>;
|
|
135
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Firehose transform Lambda's envelope: the batch handler that wraps
|
|
3
|
+
* `mapRecord`. This is step 5 of
|
|
4
|
+
* [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md) -
|
|
5
|
+
* "drops records the schema cannot accept, emitting them to the Firehose error
|
|
6
|
+
* prefix rather than failing the batch" - plus the cold-start secret read step
|
|
7
|
+
* 3 calls for.
|
|
8
|
+
*
|
|
9
|
+
* It owns four things and nothing else: the envelope types, the base64 and
|
|
10
|
+
* JSON boundary, the cold-start read of the long-lived salt secret, and the
|
|
11
|
+
* translation of one `MapRecordResult` into one Firehose response entry. Every
|
|
12
|
+
* decision about *what a row is* - which field fills which column, which
|
|
13
|
+
* values are unusable, how `visitor_key` is derived and under which day's salt
|
|
14
|
+
* - belongs to `map-record.ts` and is forwarded here, never re-decided.
|
|
15
|
+
*
|
|
16
|
+
* ## The two outcomes, and why they are different
|
|
17
|
+
*
|
|
18
|
+
* Firehose's per-record vocabulary distinguishes them and so does this module:
|
|
19
|
+
*
|
|
20
|
+
* - **`Ok`** carries the mapped row, re-encoded. Firehose writes it.
|
|
21
|
+
* - **`ProcessingFailed`** carries no data. Firehose writes the *original*
|
|
22
|
+
* record to the error prefix and delivers the rest of the batch. That is the
|
|
23
|
+
* visible data-quality signal a record the schema cannot accept should
|
|
24
|
+
* produce.
|
|
25
|
+
*
|
|
26
|
+
* Firehose's third result, `Dropped`, is deliberately unused: it discards the
|
|
27
|
+
* record silently, which is the failure mode this whole pipeline is written
|
|
28
|
+
* against. A record that cannot be mapped must end up somewhere an operator
|
|
29
|
+
* can read it.
|
|
30
|
+
*
|
|
31
|
+
* `recordId` is echoed verbatim onto every entry, `Ok` and `ProcessingFailed`
|
|
32
|
+
* alike. Firehose discards a response whose ids do not match the request's -
|
|
33
|
+
* not the mismatched entry, the response - so an id dropped from a failed
|
|
34
|
+
* entry loses the whole batch.
|
|
35
|
+
*
|
|
36
|
+
* ## Why there is no try/catch around the mapping
|
|
37
|
+
*
|
|
38
|
+
* `dailySalt` and `visitorKey` throw on an empty secret, day, IP or salt
|
|
39
|
+
* (`visitor-key.ts`), because an unsalted digest that looks protected is worse
|
|
40
|
+
* than a failed batch: the table stores `user_agent` in the clear beside
|
|
41
|
+
* `visitor_key`, and an unsalted SHA-256 of an IPv4 address is a lookup table
|
|
42
|
+
* over a 2^32 space. A blanket catch around `mapRecord` would convert those
|
|
43
|
+
* throws into `ProcessingFailed` for every record - the whole batch to the
|
|
44
|
+
* error prefix, no error anywhere, an empty dashboard, and nothing to say why.
|
|
45
|
+
* So the only `try` in this module is the one around `JSON.parse`, which is
|
|
46
|
+
* this module's own boundary. A throw from the mapping propagates, the
|
|
47
|
+
* invocation fails, and Firehose retries the batch and raises its own error
|
|
48
|
+
* metric. Loudly is the point.
|
|
49
|
+
*
|
|
50
|
+
* The same reasoning governs the secret: a failed or empty read fails the
|
|
51
|
+
* batch. There is no unsalted or date-only fallback, because a fallback would
|
|
52
|
+
* write unprotected data that looks protected, and no reprocessing could
|
|
53
|
+
* repair rows already written under it.
|
|
54
|
+
*
|
|
55
|
+
* ## No AWS SDK, no network, no clock
|
|
56
|
+
*
|
|
57
|
+
* The envelope types below are repo-owned declarations, not `@types/aws-lambda`
|
|
58
|
+
* - the package takes no dependency for a shape this small and this stable.
|
|
59
|
+
* The one thing this module cannot do without is the secret, and it reaches it
|
|
60
|
+
* through {@link SaltSecretStore}, a structural slice of core's own client
|
|
61
|
+
* (the `packages/pds/src/secret.ts` precedent). That import is type-only, so it
|
|
62
|
+
* erases at compile: this module carries no client, no signer and no transport,
|
|
63
|
+
* and its tests stub the store with a plain object rather than mocking a module
|
|
64
|
+
* or reaching a cloud.
|
|
65
|
+
*
|
|
66
|
+
* Which also means this module is not a composition root. Binding a real
|
|
67
|
+
* `SecretsManagerClient` - built over the us-east-1 signer, since the function
|
|
68
|
+
* and its secret both live there - is the bundle entry's job, and it is the
|
|
69
|
+
* caller that hands the constructed handler to Lambda.
|
|
70
|
+
*/
|
|
71
|
+
import { mapRecord } from './map-record.js';
|
|
72
|
+
/** Firehose writes the record. */
|
|
73
|
+
const OK = 'Ok';
|
|
74
|
+
/** Firehose writes the *original* record to the error prefix and carries on. */
|
|
75
|
+
const PROCESSING_FAILED = 'ProcessingFailed';
|
|
76
|
+
/**
|
|
77
|
+
* The environment variable naming the Secrets Manager secret behind
|
|
78
|
+
* `visitor_key`, set on the function by the `analytics-transform-function`
|
|
79
|
+
* node (task 50). The *name* travels in the environment; the value never does,
|
|
80
|
+
* so it cannot be read off the function's configuration.
|
|
81
|
+
*/
|
|
82
|
+
export const SALT_SECRET_NAME_ENV = 'ANALYTICS_SALT_SECRET_NAME';
|
|
83
|
+
/** The secret's name, or a failure naming the variable that should carry it. */
|
|
84
|
+
function requireSaltSecretName(env) {
|
|
85
|
+
const name = env[SALT_SECRET_NAME_ENV]?.trim();
|
|
86
|
+
if (name === undefined || name === '') {
|
|
87
|
+
throw new Error(`the analytics transform needs the salt secret's name in ${SALT_SECRET_NAME_ENV}: without it every visitor_key would be unsalted, so the function refuses to start`);
|
|
88
|
+
}
|
|
89
|
+
return name;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The stored secret, or a failure. Never the empty string and never a
|
|
93
|
+
* substitute: `dailySalt` would throw on a blank secret anyway, and this says
|
|
94
|
+
* which secret is blank and how to fix it.
|
|
95
|
+
*
|
|
96
|
+
* The value is returned untrimmed. It is opaque key material, and trimming it
|
|
97
|
+
* would derive a different salt from the same secret - orphaning every
|
|
98
|
+
* `visitor_key` already written.
|
|
99
|
+
*/
|
|
100
|
+
async function loadSaltSecret(secrets, secretName) {
|
|
101
|
+
const secret = await secrets.getSecretValue(secretName);
|
|
102
|
+
if (secret === undefined || secret.trim() === '') {
|
|
103
|
+
throw new Error(`the analytics salt secret "${secretName}" holds no value: an unsalted visitor_key would identify the visitor it exists to hide, so this batch fails instead - run \`blogwright analytics bootstrap\` to create the secret`);
|
|
104
|
+
}
|
|
105
|
+
return secret;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The record's payload as an object, or `undefined` when it is not one.
|
|
109
|
+
*
|
|
110
|
+
* `Buffer.from(data, 'base64')` never throws - it ignores what it cannot
|
|
111
|
+
* decode - so a corrupt payload surfaces here as JSON that will not parse.
|
|
112
|
+
* `mapRecord` documents that it trusts its record to be an object and that
|
|
113
|
+
* parsing is this module's boundary, which is why a non-object payload (a
|
|
114
|
+
* bare number, `null`, an array) is rejected here rather than there.
|
|
115
|
+
*
|
|
116
|
+
* This `try` covers `JSON.parse` and nothing else. Widening it to cover the
|
|
117
|
+
* mapping would turn `visitor-key.ts`'s deliberate throws into silent drops.
|
|
118
|
+
*/
|
|
119
|
+
function decodePayload(data) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(Buffer.from(data, 'base64').toString('utf8'));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
128
|
+
return undefined;
|
|
129
|
+
return parsed;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* One record in, one response entry out. The record is mapped with the whole
|
|
133
|
+
* batch's secret but under its *own* day's salt, which `mapRecord` derives
|
|
134
|
+
* from the record's own `timestamp(ms)` - a buffer that straddles midnight
|
|
135
|
+
* carries records of two days, and one salt chosen per invocation would be the
|
|
136
|
+
* wrong day's for every record on the far side of it.
|
|
137
|
+
*/
|
|
138
|
+
function transformRecord(record, saltSecret) {
|
|
139
|
+
const payload = decodePayload(record.data);
|
|
140
|
+
if (payload === undefined)
|
|
141
|
+
return { recordId: record.recordId, result: PROCESSING_FAILED };
|
|
142
|
+
const mapped = mapRecord(payload, saltSecret);
|
|
143
|
+
if (!mapped.mapped)
|
|
144
|
+
return { recordId: record.recordId, result: PROCESSING_FAILED };
|
|
145
|
+
return {
|
|
146
|
+
recordId: record.recordId,
|
|
147
|
+
result: OK,
|
|
148
|
+
data: Buffer.from(JSON.stringify(mapped.row), 'utf8').toString('base64'),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Build the Lambda entry point over a Secrets Manager read.
|
|
153
|
+
*
|
|
154
|
+
* The secret's name is resolved now, so a function deployed without
|
|
155
|
+
* {@link SALT_SECRET_NAME_ENV} fails at initialisation rather than once per
|
|
156
|
+
* batch. Its *value* is read on the first invocation and cached for the life
|
|
157
|
+
* of the execution environment: a 60-second Firehose buffer is roughly 43,000
|
|
158
|
+
* invocations a month, so reading per invocation would spend more than half
|
|
159
|
+
* the price of the secret itself again on `GetSecretValue` calls, for nothing.
|
|
160
|
+
*
|
|
161
|
+
* A failed read is not cached. Caching the failure would let one throttled
|
|
162
|
+
* call disable an execution environment for its whole life, failing every
|
|
163
|
+
* batch it ever sees; leaving it uncached means the next invocation retries.
|
|
164
|
+
*/
|
|
165
|
+
export function createTransformHandler(secrets, env = process.env) {
|
|
166
|
+
const secretName = requireSaltSecretName(env);
|
|
167
|
+
let cached;
|
|
168
|
+
async function readSaltSecret() {
|
|
169
|
+
if (cached === undefined)
|
|
170
|
+
cached = await loadSaltSecret(secrets, secretName);
|
|
171
|
+
return cached;
|
|
172
|
+
}
|
|
173
|
+
return async function transformFirehoseRecords(request) {
|
|
174
|
+
const saltSecret = await readSaltSecret();
|
|
175
|
+
return { records: request.records.map((record) => transformRecord(record, saltSecret)) };
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mapRecord`: one CloudFront standard-logging (v2) record in, one `page_views`
|
|
3
|
+
* row out - or a droppable result naming the column that could not be filled
|
|
4
|
+
* and the CloudFront field behind it. This is all five steps of
|
|
5
|
+
* [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md):
|
|
6
|
+
* rename each selected field to its column, derive `event_time` and the `day`
|
|
7
|
+
* partition from `timestamp(ms)`, replace the viewer IP with `visitor_key`,
|
|
8
|
+
* set `is_bot` from the user agent, and drop what the schema cannot accept.
|
|
9
|
+
*
|
|
10
|
+
* The viewer IP is an *input* here and never a value. It reaches `visitorKey`
|
|
11
|
+
* and nothing else: it has no `FIELD_TO_COLUMN` entry to be renamed through,
|
|
12
|
+
* and `map-record.test.ts` searches every value of every produced row for it.
|
|
13
|
+
* That search is the standing check that no later field addition puts a raw
|
|
14
|
+
* address in the warehouse.
|
|
15
|
+
*
|
|
16
|
+
* The salt secret is a parameter for the same reason the day is read off the
|
|
17
|
+
* record: this function has no clock and reads no secret. The caller supplies
|
|
18
|
+
* the long-lived stored secret - the transform reads it once at cold start, a
|
|
19
|
+
* backfill reads the same one - and the day's salt is derived here rather than
|
|
20
|
+
* by the caller, because the day comes from the record's own timestamp. A
|
|
21
|
+
* batch can straddle midnight, so one salt chosen for a whole batch would be
|
|
22
|
+
* the wrong day's for the records on the other side of it.
|
|
23
|
+
*
|
|
24
|
+
* Why the drop path exists at all: Firehose matches incoming JSON keys to
|
|
25
|
+
* Iceberg column names **exactly**, and a record it cannot match goes to the
|
|
26
|
+
* S3 error bucket with no error anywhere an operator will see it - the symptom
|
|
27
|
+
* is an empty dashboard. So this module never emits a key that is not a column
|
|
28
|
+
* and never emits a value whose JavaScript type is not the one the column
|
|
29
|
+
* stores. Where it cannot honour that, it returns the droppable result and the
|
|
30
|
+
* envelope (task 42) reports `ProcessingFailed` for that one record, which
|
|
31
|
+
* routes it to the error prefix without failing the batch.
|
|
32
|
+
*
|
|
33
|
+
* Every column and field name comes from `schema.ts`: the row is built by
|
|
34
|
+
* iterating `FIELD_TO_COLUMN`, the required set and the numeric set are
|
|
35
|
+
* derived from `PAGE_VIEWS_COLUMNS`, and the only names spelled here are the
|
|
36
|
+
* four derived columns and the one column the two of them read back
|
|
37
|
+
* (`user_agent`), each checked against `PageViewColumnName` so a typo is a
|
|
38
|
+
* compile error rather than a column Firehose silently drops.
|
|
39
|
+
*
|
|
40
|
+
* Three input decisions the spec left open, settled here:
|
|
41
|
+
*
|
|
42
|
+
* - **`-` and the empty string mean absent.** CloudFront writes `-` for a
|
|
43
|
+
* field the request had nothing to say for (no referrer, no query string).
|
|
44
|
+
* Writing that through would fill `referrer` with a wall of `-`; treating it
|
|
45
|
+
* as absent leaves the column null, which is what it means. For a *required*
|
|
46
|
+
* column it is a drop, not a null.
|
|
47
|
+
* - **A number where a string column is expected is rendered, not rejected.**
|
|
48
|
+
* `asn` is a string column that a JSON encoder may well emit unquoted;
|
|
49
|
+
* `String(64512)` is the same value in the type the column stores. Anything
|
|
50
|
+
* that is not a string or a number - an object, an array, a boolean - is a
|
|
51
|
+
* drop, because there is no rendering of it that is not a guess.
|
|
52
|
+
* - **A numeric column that does not parse is a drop, never a coerced value.**
|
|
53
|
+
* `Number('abc')` is `NaN` and `Number('')` is `0`; writing either would put
|
|
54
|
+
* a wrong number in the table, which is worse than losing the record.
|
|
55
|
+
*
|
|
56
|
+
* Nullability governs the *absent* case only. A value that is present but
|
|
57
|
+
* unusable drops the record whether or not the column is nullable: leaving a
|
|
58
|
+
* nullable column empty asserts the request had nothing to say for it, which
|
|
59
|
+
* is a different - and false - fact about that request.
|
|
60
|
+
*
|
|
61
|
+
* Pure: no clock (`event_time` comes from the record's own `timestamp(ms)`, in
|
|
62
|
+
* UTC), no secret read, no vendor SDK, no `fetch`. The one `node:` builtin
|
|
63
|
+
* this directory uses is `node:crypto`, reached through `visitor-key.ts` and
|
|
64
|
+
* only for the digests - the same import `packages/core/src/aws/s3.ts` makes.
|
|
65
|
+
* The record arrives already parsed and is trusted to be an object - JSON
|
|
66
|
+
* parsing and its failures are the handler's boundary, not this function's.
|
|
67
|
+
*/
|
|
68
|
+
import { type PageView, type PageViewColumnName } from '../schema.js';
|
|
69
|
+
/**
|
|
70
|
+
* One CloudFront access-log record as the delivery hands it over: the selected
|
|
71
|
+
* field names of `CLOUDFRONT_RECORD_FIELDS` against values of whatever type
|
|
72
|
+
* the JSON payload carried.
|
|
73
|
+
*/
|
|
74
|
+
export type CloudFrontRecord = Readonly<Record<string, unknown>>;
|
|
75
|
+
/** A record that maps cleanly: the row is complete and safe to hand Firehose. */
|
|
76
|
+
interface MappedRecord {
|
|
77
|
+
readonly mapped: true;
|
|
78
|
+
readonly row: PageView;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A record the schema cannot accept. It carries no row at all - a partially
|
|
82
|
+
* populated one is exactly the silent corruption the drop path exists to
|
|
83
|
+
* prevent.
|
|
84
|
+
*/
|
|
85
|
+
interface DroppedRecord {
|
|
86
|
+
readonly mapped: false;
|
|
87
|
+
/** The `page_views` column that could not be filled. */
|
|
88
|
+
readonly column: PageViewColumnName;
|
|
89
|
+
/** The CloudFront field that column reads. */
|
|
90
|
+
readonly field: string;
|
|
91
|
+
/** Names both, plus what was wrong with the value. */
|
|
92
|
+
readonly reason: string;
|
|
93
|
+
}
|
|
94
|
+
/** The outcome of mapping one record: a complete row, or a named drop. */
|
|
95
|
+
export type MapRecordResult = MappedRecord | DroppedRecord;
|
|
96
|
+
/**
|
|
97
|
+
* Turns one CloudFront access-log record into a `page_views` row, or reports
|
|
98
|
+
* why it cannot. Never returns a partially populated row: the first column it
|
|
99
|
+
* cannot fill ends the mapping.
|
|
100
|
+
*
|
|
101
|
+
* `saltSecret` is the long-lived stored secret behind `visitor_key`, not the
|
|
102
|
+
* day's salt - that is derived here from the record's own day. It is required
|
|
103
|
+
* rather than optional on purpose: a row mapped without it would carry no
|
|
104
|
+
* visitor at all, or worse an unsalted digest, and either would look like a
|
|
105
|
+
* normal row in the table. `dailySalt` throws on an empty secret for the same
|
|
106
|
+
* reason, so a caller whose secret read failed fails its batch instead of
|
|
107
|
+
* quietly writing unprotected data.
|
|
108
|
+
*/
|
|
109
|
+
export declare function mapRecord(record: CloudFrontRecord, saltSecret: string): MapRecordResult;
|
|
110
|
+
export {};
|