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
package/dist/queries.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed set of named, parameterised queries this package answers from -
|
|
3
|
+
* never SQL supplied by a client. Seven of them are the dashboard's panels;
|
|
4
|
+
* see [the change spec's §Analytics dashboard → Local
|
|
5
|
+
* server](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md). The eighth,
|
|
6
|
+
* {@link ROW_COUNT_QUERY}, serves `analytics status`, and is here for the same
|
|
7
|
+
* reason the other seven are: a command reaches the table through the
|
|
8
|
+
* `AnalyticsQuery` port, and the port takes a name from this set, never a
|
|
9
|
+
* statement.
|
|
10
|
+
*
|
|
11
|
+
* **Parameterised is a shape here, not a convention.** A definition's SQL is
|
|
12
|
+
* built by the {@link sql} tag, whose only substitution slot accepts
|
|
13
|
+
* {@link SqlRelation} - a branded type whose single inhabitant is
|
|
14
|
+
* {@link PAGE_VIEWS}, declared in this module. Interpolating anything else is
|
|
15
|
+
* a compile error (`TS2345: Argument of type 'string' is not assignable to
|
|
16
|
+
* parameter of type 'SqlRelation'`), and a plain string cannot be assigned to
|
|
17
|
+
* {@link SqlText} either (`TS2322`), so a definition has nowhere to put a
|
|
18
|
+
* caller's value: caller values reach the statement only as `$name`
|
|
19
|
+
* placeholders, bound by {@link prepareQuery}. The tag is module-private, so
|
|
20
|
+
* no other module can mint SQL for this port at all.
|
|
21
|
+
*
|
|
22
|
+
* **The type-level block is the one holding the property; the runtime tests
|
|
23
|
+
* are a partial net under it, not a second copy of it.** `tsc` rejects *every*
|
|
24
|
+
* splice of a caller value that is not deliberately cast, and `pnpm typecheck`
|
|
25
|
+
* runs it in CI (`.github/workflows/ci.yml:22`). The tests in
|
|
26
|
+
* `queries.test.ts` catch only what a splice leaves visible in the finished
|
|
27
|
+
* string - a quoted literal the definition did not declare, or anything
|
|
28
|
+
* day-shaped. They do not catch the rest: splicing `'0 OR 1=1'` into
|
|
29
|
+
* `status-codes` as `` AND status >= ${'0 OR 1=1'} `` leaves the whole suite
|
|
30
|
+
* green while `tsc` reports `TS2345` (observed, 2026-08-30). What the net does
|
|
31
|
+
* still add is independence over the forms it does cover: vitest transpiles
|
|
32
|
+
* without typechecking, so `` AND day >= '${'2026-08-01' as SqlRelation}' ``
|
|
33
|
+
* passes `tsc` and reddens the parameterisation test anyway. Read the two as a
|
|
34
|
+
* check total up to a deliberate cast and a partial backstop under it, not as
|
|
35
|
+
* equals - weakening or dropping the branded types because "the tests cover
|
|
36
|
+
* it" would leave only the partial net.
|
|
37
|
+
*
|
|
38
|
+
* **Why the relation is a fixed name rather than the configured triple.** SQL
|
|
39
|
+
* binds *values*, not identifiers: a configured `<tableBucket>/<namespace>/
|
|
40
|
+
* <table>` spliced into the statement would be exactly the interpolation this
|
|
41
|
+
* module exists to make impossible. So every definition reads one relation,
|
|
42
|
+
* {@link PAGE_VIEWS_RELATION}, and binding that name to the configured triple
|
|
43
|
+
* is the adapter's job - it holds the plugin context, so it takes
|
|
44
|
+
* `resolveAnalyticsConfig(ctx)` and attaches or aliases accordingly. The
|
|
45
|
+
* configurability task 44 declares is therefore preserved, in the one place
|
|
46
|
+
* that has the environment to resolve it.
|
|
47
|
+
*
|
|
48
|
+
* Pure data and pure functions only: no `node:` builtin, no vendor SDK, no
|
|
49
|
+
* `fetch`. Nothing here runs a statement - that is `AnalyticsQuery`'s adapter.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* The relation every named query reads. Not "the configured table name" - the
|
|
53
|
+
* adapter binds this name to the configured `<namespace>.<table>` inside the
|
|
54
|
+
* attached catalog before it runs anything, and this module never sees the
|
|
55
|
+
* configuration. See the module doc comment for why an identifier cannot be a
|
|
56
|
+
* bind parameter.
|
|
57
|
+
*/
|
|
58
|
+
export const PAGE_VIEWS_RELATION = 'page_views';
|
|
59
|
+
/** {@link PAGE_VIEWS_RELATION} as the one value the {@link sql} tag will splice. */
|
|
60
|
+
const PAGE_VIEWS = PAGE_VIEWS_RELATION;
|
|
61
|
+
/**
|
|
62
|
+
* Build a definition's statement. The rest parameter is typed
|
|
63
|
+
* {@link SqlRelation}, so `` sql`... WHERE day > ${params.range.from}` `` does
|
|
64
|
+
* not compile; a caller's value has to go through a `$name` placeholder.
|
|
65
|
+
*/
|
|
66
|
+
function sql(fragments, ...relations) {
|
|
67
|
+
return fragments
|
|
68
|
+
.reduce((text, fragment, index) => text + (relations[index - 1] ?? '') + fragment)
|
|
69
|
+
.trim();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The parameters a definition binds, as `$name` placeholders in its SQL. Every
|
|
73
|
+
* query takes the date range and the bot-inclusion flag the spec requires of
|
|
74
|
+
* all of them; the list is per-definition so a statement that forgot one is a
|
|
75
|
+
* test failure naming that query rather than a filter that quietly never ran.
|
|
76
|
+
*/
|
|
77
|
+
const QUERY_PARAM_NAMES = ['from', 'to', 'include_bots'];
|
|
78
|
+
/**
|
|
79
|
+
* The `x-edge-result-type` values CloudFront reports for a cache hit. AWS
|
|
80
|
+
* documents that field's values as `Hit`, `RefreshHit`, `Miss`,
|
|
81
|
+
* `LimitExceeded`, `CapacityExceeded`, `Error` and `Redirect`; the first two
|
|
82
|
+
* are the hits. `OriginShieldHit` is deliberately absent - it is an
|
|
83
|
+
* `x-edge-detailed-result-type` value, and `schema.ts` selects
|
|
84
|
+
* `x-edge-result-type` rather than the detailed field, so it can never appear
|
|
85
|
+
* in `result_type`.
|
|
86
|
+
*/
|
|
87
|
+
const CACHE_HIT_RESULT_TYPES = ['Hit', 'RefreshHit'];
|
|
88
|
+
/**
|
|
89
|
+
* The name of the row-count query. Not one of the seven the spec's §Local
|
|
90
|
+
* server lists - those answer the dashboard's panels; this one answers
|
|
91
|
+
* `analytics status`, which reports the table's current row count beside the
|
|
92
|
+
* plugin's twelve nodes. It lives in this set rather than in the command
|
|
93
|
+
* because the command may not write SQL: every statement this package runs is
|
|
94
|
+
* one of these definitions, reached through the `AnalyticsQuery` port.
|
|
95
|
+
*/
|
|
96
|
+
export const ROW_COUNT_QUERY = 'row-count';
|
|
97
|
+
/** The one column {@link ROW_COUNT_QUERY} selects. Named so no caller spells it twice. */
|
|
98
|
+
export const ROW_COUNT_COLUMN = 'row_count';
|
|
99
|
+
/**
|
|
100
|
+
* The range {@link ROW_COUNT_QUERY} is asked over when the caller wants the
|
|
101
|
+
* whole table, as `analytics status` does.
|
|
102
|
+
*
|
|
103
|
+
* Every definition in this set is bounded on the `day` partition - the spec
|
|
104
|
+
* requires the range and the bot flag of all of them - so "the whole table" is
|
|
105
|
+
* expressed as the widest range the column can hold rather than as an
|
|
106
|
+
* unbounded statement. Both ends are calendar days {@link isCalendarDay}
|
|
107
|
+
* accepts, so this constant goes through exactly the validation a caller's
|
|
108
|
+
* range does. `from` is the Unix epoch, which no `day` can precede: the column
|
|
109
|
+
* is derived from the request's own timestamp. `to` is the last day of the
|
|
110
|
+
* four-digit years, which is the largest day this module's `YYYY-MM-DD` shape
|
|
111
|
+
* can express at all.
|
|
112
|
+
*/
|
|
113
|
+
export const WHOLE_TABLE_RANGE = { from: '1970-01-01', to: '9999-12-31' };
|
|
114
|
+
/**
|
|
115
|
+
* Every named query, keyed by the name a caller asks for: the seven the spec's
|
|
116
|
+
* §Local server lists, in its order, and then {@link ROW_COUNT_QUERY}, which
|
|
117
|
+
* no panel draws and `analytics status` reports.
|
|
118
|
+
*
|
|
119
|
+
* Every statement reads {@link PAGE_VIEWS}, bounds itself on the `day`
|
|
120
|
+
* partition with `$from`/`$to`, and honours `$include_bots` - a row whose
|
|
121
|
+
* `is_bot` is null counts as not-a-bot, since the transform leaves the column
|
|
122
|
+
* absent when it has nothing to say.
|
|
123
|
+
*
|
|
124
|
+
* **`unique-visitors` is the one whose semantic cannot be read off its name.**
|
|
125
|
+
* `visitor_key` is a daily-salted digest and the salt turns over at every UTC
|
|
126
|
+
* day boundary (the spec's Decision *Daily salt rotation stands*, settled
|
|
127
|
+
* 2026-07-27), so the same person is a different `visitor_key` tomorrow.
|
|
128
|
+
* A `count(DISTINCT visitor_key)` spanning a range therefore does not error -
|
|
129
|
+
* it returns a plausible number that means nothing. The definition counts
|
|
130
|
+
* distinct keys *within* a day and reports the range total as the sum of those
|
|
131
|
+
* daily counts, and says so in its column names (`daily_unique_visitors`,
|
|
132
|
+
* `summed_daily_unique_visitors`) and its `rowMeaning`, so a dashboard cannot
|
|
133
|
+
* relabel the total "unique visitors" without deleting the words that say
|
|
134
|
+
* otherwise. The sum over-counts a visitor who returns on another day; that is
|
|
135
|
+
* the accepted cost of bounding what one day of brute-forced salt could ever
|
|
136
|
+
* correlate.
|
|
137
|
+
*/
|
|
138
|
+
export const ANALYTICS_QUERIES = {
|
|
139
|
+
'views-over-time': {
|
|
140
|
+
rowMeaning: 'one UTC day and the number of requests served that day',
|
|
141
|
+
columns: ['day', 'is_bot'],
|
|
142
|
+
binds: ['from', 'to', 'include_bots'],
|
|
143
|
+
resultColumns: ['day', 'views'],
|
|
144
|
+
literals: [],
|
|
145
|
+
sql: sql `
|
|
146
|
+
SELECT day, count(*) AS views
|
|
147
|
+
FROM ${PAGE_VIEWS}
|
|
148
|
+
WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
|
|
149
|
+
AND ($include_bots OR NOT coalesce(is_bot, false))
|
|
150
|
+
GROUP BY day
|
|
151
|
+
ORDER BY day
|
|
152
|
+
`,
|
|
153
|
+
},
|
|
154
|
+
'top-paths': {
|
|
155
|
+
rowMeaning: 'one request path and the number of requests for it over the range',
|
|
156
|
+
columns: ['day', 'uri', 'is_bot'],
|
|
157
|
+
binds: ['from', 'to', 'include_bots'],
|
|
158
|
+
resultColumns: ['uri', 'views'],
|
|
159
|
+
literals: [],
|
|
160
|
+
sql: sql `
|
|
161
|
+
SELECT uri, count(*) AS views
|
|
162
|
+
FROM ${PAGE_VIEWS}
|
|
163
|
+
WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
|
|
164
|
+
AND ($include_bots OR NOT coalesce(is_bot, false))
|
|
165
|
+
GROUP BY uri
|
|
166
|
+
ORDER BY views DESC, uri
|
|
167
|
+
`,
|
|
168
|
+
},
|
|
169
|
+
referrers: {
|
|
170
|
+
rowMeaning: 'one referring URL and the number of requests it sent over the range',
|
|
171
|
+
columns: ['day', 'referrer', 'is_bot'],
|
|
172
|
+
binds: ['from', 'to', 'include_bots'],
|
|
173
|
+
resultColumns: ['referrer', 'views'],
|
|
174
|
+
literals: [],
|
|
175
|
+
sql: sql `
|
|
176
|
+
SELECT referrer, count(*) AS views
|
|
177
|
+
FROM ${PAGE_VIEWS}
|
|
178
|
+
WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
|
|
179
|
+
AND ($include_bots OR NOT coalesce(is_bot, false))
|
|
180
|
+
AND referrer IS NOT NULL
|
|
181
|
+
GROUP BY referrer
|
|
182
|
+
ORDER BY views DESC, referrer
|
|
183
|
+
`,
|
|
184
|
+
},
|
|
185
|
+
countries: {
|
|
186
|
+
rowMeaning: 'one viewer country and the number of requests from it over the range',
|
|
187
|
+
columns: ['day', 'country', 'is_bot'],
|
|
188
|
+
binds: ['from', 'to', 'include_bots'],
|
|
189
|
+
resultColumns: ['country', 'views'],
|
|
190
|
+
literals: [],
|
|
191
|
+
sql: sql `
|
|
192
|
+
SELECT country, count(*) AS views
|
|
193
|
+
FROM ${PAGE_VIEWS}
|
|
194
|
+
WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
|
|
195
|
+
AND ($include_bots OR NOT coalesce(is_bot, false))
|
|
196
|
+
AND country IS NOT NULL
|
|
197
|
+
GROUP BY country
|
|
198
|
+
ORDER BY views DESC, country
|
|
199
|
+
`,
|
|
200
|
+
},
|
|
201
|
+
'status-codes': {
|
|
202
|
+
rowMeaning: 'one HTTP status code and the number of responses carrying it over the range',
|
|
203
|
+
columns: ['day', 'status', 'is_bot'],
|
|
204
|
+
binds: ['from', 'to', 'include_bots'],
|
|
205
|
+
resultColumns: ['status', 'views'],
|
|
206
|
+
literals: [],
|
|
207
|
+
sql: sql `
|
|
208
|
+
SELECT status, count(*) AS views
|
|
209
|
+
FROM ${PAGE_VIEWS}
|
|
210
|
+
WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
|
|
211
|
+
AND ($include_bots OR NOT coalesce(is_bot, false))
|
|
212
|
+
GROUP BY status
|
|
213
|
+
ORDER BY status
|
|
214
|
+
`,
|
|
215
|
+
},
|
|
216
|
+
'cache-hit-ratio': {
|
|
217
|
+
rowMeaning: 'one UTC day, its requests, the edge cache hits among them, and hits divided by requests',
|
|
218
|
+
columns: ['day', 'result_type', 'is_bot'],
|
|
219
|
+
binds: ['from', 'to', 'include_bots'],
|
|
220
|
+
resultColumns: ['day', 'requests', 'cache_hits', 'cache_hit_ratio'],
|
|
221
|
+
literals: CACHE_HIT_RESULT_TYPES,
|
|
222
|
+
sql: sql `
|
|
223
|
+
WITH daily AS (
|
|
224
|
+
SELECT day,
|
|
225
|
+
count(*) AS requests,
|
|
226
|
+
count(*) FILTER (WHERE result_type IN ('Hit', 'RefreshHit')) AS cache_hits
|
|
227
|
+
FROM ${PAGE_VIEWS}
|
|
228
|
+
WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
|
|
229
|
+
AND ($include_bots OR NOT coalesce(is_bot, false))
|
|
230
|
+
GROUP BY day
|
|
231
|
+
)
|
|
232
|
+
SELECT day, requests, cache_hits, CAST(cache_hits AS DOUBLE) / requests AS cache_hit_ratio
|
|
233
|
+
FROM daily
|
|
234
|
+
ORDER BY day
|
|
235
|
+
`,
|
|
236
|
+
},
|
|
237
|
+
'unique-visitors': {
|
|
238
|
+
rowMeaning: 'one UTC day and its distinct visitor_key count, beside the range total - the sum of those daily counts, not a distinct count across days, because the salt rotates daily',
|
|
239
|
+
columns: ['day', 'visitor_key', 'is_bot'],
|
|
240
|
+
binds: ['from', 'to', 'include_bots'],
|
|
241
|
+
resultColumns: ['day', 'daily_unique_visitors', 'summed_daily_unique_visitors'],
|
|
242
|
+
literals: [],
|
|
243
|
+
sql: sql `
|
|
244
|
+
WITH daily AS (
|
|
245
|
+
SELECT day, count(DISTINCT visitor_key) AS daily_unique_visitors
|
|
246
|
+
FROM ${PAGE_VIEWS}
|
|
247
|
+
WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
|
|
248
|
+
AND ($include_bots OR NOT coalesce(is_bot, false))
|
|
249
|
+
AND visitor_key IS NOT NULL
|
|
250
|
+
GROUP BY day
|
|
251
|
+
)
|
|
252
|
+
SELECT day,
|
|
253
|
+
daily_unique_visitors,
|
|
254
|
+
sum(daily_unique_visitors) OVER () AS summed_daily_unique_visitors
|
|
255
|
+
FROM daily
|
|
256
|
+
ORDER BY day
|
|
257
|
+
`,
|
|
258
|
+
},
|
|
259
|
+
/**
|
|
260
|
+
* Not a dashboard panel: the figure `analytics status` reports beside the
|
|
261
|
+
* node listing, so an operator can tell "the pipeline is provisioned" from
|
|
262
|
+
* "the pipeline has delivered something". Bots are counted when the caller
|
|
263
|
+
* asks for them - a row is a row - which is why the status command binds
|
|
264
|
+
* `include_bots` explicitly rather than leaving it to `config.analytics.bots`.
|
|
265
|
+
*/
|
|
266
|
+
[ROW_COUNT_QUERY]: {
|
|
267
|
+
rowMeaning: 'the number of rows the table holds over the range, one row carrying the count',
|
|
268
|
+
columns: ['day', 'is_bot'],
|
|
269
|
+
binds: ['from', 'to', 'include_bots'],
|
|
270
|
+
resultColumns: [ROW_COUNT_COLUMN],
|
|
271
|
+
literals: [],
|
|
272
|
+
sql: sql `
|
|
273
|
+
SELECT count(*) AS row_count
|
|
274
|
+
FROM ${PAGE_VIEWS}
|
|
275
|
+
WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
|
|
276
|
+
AND ($include_bots OR NOT coalesce(is_bot, false))
|
|
277
|
+
`,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* Every query name, in declaration order - what the unknown-name error lists
|
|
282
|
+
* and what the test suite iterates. Derived from the table rather than
|
|
283
|
+
* restated beside it, so the two cannot drift; the cast is `Object.keys`'
|
|
284
|
+
* `string[]` narrowed back to the keys it just enumerated.
|
|
285
|
+
*/
|
|
286
|
+
export const ANALYTICS_QUERY_NAMES = Object.keys(ANALYTICS_QUERIES);
|
|
287
|
+
/**
|
|
288
|
+
* Whether bot-flagged rows are counted when a caller states no preference, per
|
|
289
|
+
* `bots` mode. Exhaustive over the union rather than a comparison against one
|
|
290
|
+
* spelling, so adding a third mode to task 44's config is a compile error here
|
|
291
|
+
* instead of a silent `false`. The *default value* is not restated: it is
|
|
292
|
+
* whatever `validateAnalyticsConfig` put on `config.bots`.
|
|
293
|
+
*/
|
|
294
|
+
const BOTS_INCLUDED_BY_DEFAULT = {
|
|
295
|
+
flag: true,
|
|
296
|
+
filter: false,
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* A `YYYY-MM-DD` day, the form the `day` partition column takes. Four digits,
|
|
300
|
+
* so the extended-year forms `Date` also understands are not days here - see
|
|
301
|
+
* {@link isCalendarDay} for which of the two checks catches what.
|
|
302
|
+
*/
|
|
303
|
+
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
304
|
+
/** Characters in a `YYYY-MM-DD` day - how much of an ISO timestamp is the day. */
|
|
305
|
+
const DAY_LENGTH = 10;
|
|
306
|
+
/** Render a rejected value for a message: strings quoted, as core's config messages quote them. */
|
|
307
|
+
function describeValue(value) {
|
|
308
|
+
return typeof value === 'string' ? `"${value}"` : String(value);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Whether `day` names a day that exists. Neither half is redundant, and they
|
|
312
|
+
* catch different things:
|
|
313
|
+
*
|
|
314
|
+
* - The round-trip is what rejects a *malformed or impossible* day, and it
|
|
315
|
+
* does most of the work. `Date.parse('2026-02-30T00:00:00Z')` does not fail,
|
|
316
|
+
* it rolls over to 2 March, so a mistyped range end would silently report a
|
|
317
|
+
* different month; comparing the parsed date's own ISO form back against the
|
|
318
|
+
* input catches that, and also catches `2026-8-1`, `20260801` and a day with
|
|
319
|
+
* a time already on it, which all parse to `NaN` here.
|
|
320
|
+
* - {@link DAY_PATTERN} is what rejects the *extended-year* forms `Date` also
|
|
321
|
+
* accepts. `'+271821-04'` is ten characters long and round-trips exactly -
|
|
322
|
+
* `new Date(Date.parse('+271821-04T00:00:00Z')).toISOString().slice(0, 10)`
|
|
323
|
+
* is `'+271821-04'` - so without the pattern it would be bound as a day and
|
|
324
|
+
* reach `CAST($from AS DATE)`. `'-000001-01'` likewise.
|
|
325
|
+
*
|
|
326
|
+
* `queries.test.ts` pins both: drop either line and a different test reddens.
|
|
327
|
+
*/
|
|
328
|
+
function isCalendarDay(day) {
|
|
329
|
+
if (!DAY_PATTERN.test(day))
|
|
330
|
+
return false;
|
|
331
|
+
const time = Date.parse(`${day}T00:00:00Z`);
|
|
332
|
+
if (Number.isNaN(time))
|
|
333
|
+
return false;
|
|
334
|
+
return new Date(time).toISOString().slice(0, DAY_LENGTH) === day;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Resolve a name to its definition, raising with the available names when it
|
|
338
|
+
* is not one of them. Takes a `string` rather than a {@link QueryName} because
|
|
339
|
+
* this is the boundary the local server's HTTP path arrives at, where the
|
|
340
|
+
* compiler's guarantee has already been erased.
|
|
341
|
+
*
|
|
342
|
+
* The lookup is guarded by `Object.hasOwn` and not by a test for `undefined`,
|
|
343
|
+
* for the reason `build.ts`'s `contentType` is: an unguarded index answers
|
|
344
|
+
* every `Object.prototype` key with an inherited function, so
|
|
345
|
+
* `GET /api/queries/constructor` would resolve to a truthy "definition" and
|
|
346
|
+
* fail later as `definition.binds is not iterable` instead of the rejection
|
|
347
|
+
* below. The names in the message are the only ones that resolve.
|
|
348
|
+
*/
|
|
349
|
+
export function queryDefinition(name) {
|
|
350
|
+
const definition = Object.hasOwn(ANALYTICS_QUERIES, name)
|
|
351
|
+
? ANALYTICS_QUERIES[name]
|
|
352
|
+
: undefined;
|
|
353
|
+
if (definition === undefined) {
|
|
354
|
+
throw new Error(`unknown analytics query ${describeValue(name)} - available queries are ${ANALYTICS_QUERY_NAMES.join(', ')}`);
|
|
355
|
+
}
|
|
356
|
+
return definition;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Check the range at the same boundary the name is checked at, naming the
|
|
360
|
+
* offending value. The declared type says `from` and `to` are present strings,
|
|
361
|
+
* and the runtime says otherwise for the same reason `config.ts`'s
|
|
362
|
+
* `unsealEnvDerivedOverrides` does: the one seam that fills these is the
|
|
363
|
+
* dashboard's query string, where every value arrives as `string | undefined`
|
|
364
|
+
* and a narrowing can be forgotten. Defaulting an absent range would be worse
|
|
365
|
+
* than raising - a chart over "some window the server picked" is indexed by
|
|
366
|
+
* nothing the reader chose.
|
|
367
|
+
*/
|
|
368
|
+
function validateRange(name, range) {
|
|
369
|
+
const from = range?.from;
|
|
370
|
+
const to = range?.to;
|
|
371
|
+
if (typeof from !== 'string' || !isCalendarDay(from)) {
|
|
372
|
+
throw new Error(`analytics query ${describeValue(name)}: range.from must be a YYYY-MM-DD calendar day, got ${describeValue(from)}`);
|
|
373
|
+
}
|
|
374
|
+
if (typeof to !== 'string' || !isCalendarDay(to)) {
|
|
375
|
+
throw new Error(`analytics query ${describeValue(name)}: range.to must be a YYYY-MM-DD calendar day, got ${describeValue(to)}`);
|
|
376
|
+
}
|
|
377
|
+
// Both are `YYYY-MM-DD`, so lexical order is chronological order.
|
|
378
|
+
if (from > to) {
|
|
379
|
+
throw new Error(`analytics query ${describeValue(name)}: range is inverted - from ${describeValue(from)} is after to ${describeValue(to)}`);
|
|
380
|
+
}
|
|
381
|
+
return { from, to };
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Resolve a name and its parameters into the statement and the bindings an
|
|
385
|
+
* adapter runs - the one boundary where an unknown name and a bad range are
|
|
386
|
+
* both rejected, so every implementation of `AnalyticsQuery` (the DuckDB
|
|
387
|
+
* adapter and the fixture-backed fake alike) refuses the same inputs with the
|
|
388
|
+
* same messages.
|
|
389
|
+
*
|
|
390
|
+
* `config` is the validated `analytics` block off `ctx.pluginConfig`, taken for
|
|
391
|
+
* its `bots` mode alone: a resolved config satisfies it too, so a caller passes
|
|
392
|
+
* whichever it is holding.
|
|
393
|
+
*/
|
|
394
|
+
export function prepareQuery(name, params, config) {
|
|
395
|
+
const definition = queryDefinition(name);
|
|
396
|
+
const range = validateRange(name, params.range);
|
|
397
|
+
const includeBots = params.includeBots ?? BOTS_INCLUDED_BY_DEFAULT[config.bots];
|
|
398
|
+
const available = {
|
|
399
|
+
from: range.from,
|
|
400
|
+
to: range.to,
|
|
401
|
+
include_bots: includeBots,
|
|
402
|
+
};
|
|
403
|
+
const bindings = {};
|
|
404
|
+
for (const bind of definition.binds)
|
|
405
|
+
bindings[bind] = available[bind];
|
|
406
|
+
return {
|
|
407
|
+
// Justified by `queryDefinition` above: it raised unless `name` is one of
|
|
408
|
+
// the table's own keys, which is exactly what `QueryName` enumerates.
|
|
409
|
+
name: name,
|
|
410
|
+
sql: definition.sql,
|
|
411
|
+
resultColumns: definition.resultColumns,
|
|
412
|
+
bindings,
|
|
413
|
+
};
|
|
414
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single home for the `page_views` Iceberg table: its column set, its
|
|
3
|
+
* `day` partition, the CloudFront standard-logging (v2) fields the delivery
|
|
4
|
+
* selects, and the mapping between the two. The transform Lambda, the table
|
|
5
|
+
* node and the delivery node all read these constants instead of restating
|
|
6
|
+
* them - see [§Table schema](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
|
|
7
|
+
*
|
|
8
|
+
* Why this file is worth being careful with: Firehose matches incoming JSON
|
|
9
|
+
* keys to Iceberg column names **exactly** and silently discards any field
|
|
10
|
+
* that does not match a column - no error, no dead-letter record, nothing
|
|
11
|
+
* that shows up in a log. A typo here does not fail loudly; it fills a
|
|
12
|
+
* column with nulls forever, and nothing points back at this file as the
|
|
13
|
+
* cause. Column names are lowercase throughout - an S3 Tables catalog
|
|
14
|
+
* requirement, not a style choice.
|
|
15
|
+
*
|
|
16
|
+
* `cs(Cookie)` and `x-forwarded-for` carry personal data and have no
|
|
17
|
+
* analytic use, so they are never selected: they never reach Firehose, the
|
|
18
|
+
* transform, or the table. This governs the analytics delivery only. The
|
|
19
|
+
* site's existing CloudWatch delivery (`packages/cli/src/nodes.ts`'s
|
|
20
|
+
* `logDeliveryNode`, wired through `packages/core/src/aws/logs.ts`'s
|
|
21
|
+
* `createDelivery`) is created with no `recordFields`, so AWS's default
|
|
22
|
+
* field list - which includes both excluded fields - still applies to that
|
|
23
|
+
* copy. Narrowing that is a separate change to the site's node, not this
|
|
24
|
+
* one; do not "fix" that inconsistency here.
|
|
25
|
+
*
|
|
26
|
+
* Field names are verified against AWS's CloudFront standard-logging (v2)
|
|
27
|
+
* documentation (docs.aws.amazon.com/AmazonCloudFront, "Configure standard
|
|
28
|
+
* logging (v2)" and "Standard logging reference", current as of 2026-08-30):
|
|
29
|
+
* the `recordFields` the CreateDelivery API accepts, and the field
|
|
30
|
+
* descriptions in the log-file-field reference.
|
|
31
|
+
*
|
|
32
|
+
* Pure data and pure functions only: no `node:` builtin, no vendor SDK, no
|
|
33
|
+
* `fetch`.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Iceberg primitive types used by the `page_views` table. Not exported: no
|
|
37
|
+
* consumer needs the bare union today - a future one can reach the same
|
|
38
|
+
* type as `PageViewsColumn['icebergType']`. Export it once a real consumer
|
|
39
|
+
* needs the union by name.
|
|
40
|
+
*/
|
|
41
|
+
type IcebergType = 'string' | 'timestamp' | 'date' | 'int' | 'long' | 'double' | 'boolean';
|
|
42
|
+
/** One `page_views` column: its Iceberg type and whether Firehose may write a null. */
|
|
43
|
+
export interface PageViewsColumn {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly icebergType: IcebergType;
|
|
46
|
+
readonly required: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The `page_views` table, in the order the spec's `PageView` `$defs` block
|
|
50
|
+
* lists it. `required` is `true` exactly for the spec's `PageView.required`
|
|
51
|
+
* set (`event_time`, `day`, `host`, `uri`, `status`); every other column may
|
|
52
|
+
* be null - CloudFront itself writes `-` for several of these when a request
|
|
53
|
+
* has nothing to say (no referrer, no query string, and so on).
|
|
54
|
+
*/
|
|
55
|
+
export declare const PAGE_VIEWS_COLUMNS: readonly [{
|
|
56
|
+
readonly name: "event_time";
|
|
57
|
+
readonly icebergType: "timestamp";
|
|
58
|
+
readonly required: true;
|
|
59
|
+
}, {
|
|
60
|
+
readonly name: "day";
|
|
61
|
+
readonly icebergType: "date";
|
|
62
|
+
readonly required: true;
|
|
63
|
+
}, {
|
|
64
|
+
readonly name: "host";
|
|
65
|
+
readonly icebergType: "string";
|
|
66
|
+
readonly required: true;
|
|
67
|
+
}, {
|
|
68
|
+
readonly name: "uri";
|
|
69
|
+
readonly icebergType: "string";
|
|
70
|
+
readonly required: true;
|
|
71
|
+
}, {
|
|
72
|
+
readonly name: "query";
|
|
73
|
+
readonly icebergType: "string";
|
|
74
|
+
readonly required: false;
|
|
75
|
+
}, {
|
|
76
|
+
readonly name: "method";
|
|
77
|
+
readonly icebergType: "string";
|
|
78
|
+
readonly required: false;
|
|
79
|
+
}, {
|
|
80
|
+
readonly name: "status";
|
|
81
|
+
readonly icebergType: "int";
|
|
82
|
+
readonly required: true;
|
|
83
|
+
}, {
|
|
84
|
+
readonly name: "referrer";
|
|
85
|
+
readonly icebergType: "string";
|
|
86
|
+
readonly required: false;
|
|
87
|
+
}, {
|
|
88
|
+
readonly name: "user_agent";
|
|
89
|
+
readonly icebergType: "string";
|
|
90
|
+
readonly required: false;
|
|
91
|
+
}, {
|
|
92
|
+
readonly name: "country";
|
|
93
|
+
readonly icebergType: "string";
|
|
94
|
+
readonly required: false;
|
|
95
|
+
}, {
|
|
96
|
+
readonly name: "asn";
|
|
97
|
+
readonly icebergType: "string";
|
|
98
|
+
readonly required: false;
|
|
99
|
+
}, {
|
|
100
|
+
readonly name: "edge_location";
|
|
101
|
+
readonly icebergType: "string";
|
|
102
|
+
readonly required: false;
|
|
103
|
+
}, {
|
|
104
|
+
readonly name: "result_type";
|
|
105
|
+
readonly icebergType: "string";
|
|
106
|
+
readonly required: false;
|
|
107
|
+
}, {
|
|
108
|
+
readonly name: "bytes_sent";
|
|
109
|
+
readonly icebergType: "long";
|
|
110
|
+
readonly required: false;
|
|
111
|
+
}, {
|
|
112
|
+
readonly name: "time_taken";
|
|
113
|
+
readonly icebergType: "double";
|
|
114
|
+
readonly required: false;
|
|
115
|
+
}, {
|
|
116
|
+
readonly name: "content_type";
|
|
117
|
+
readonly icebergType: "string";
|
|
118
|
+
readonly required: false;
|
|
119
|
+
}, {
|
|
120
|
+
readonly name: "protocol";
|
|
121
|
+
readonly icebergType: "string";
|
|
122
|
+
readonly required: false;
|
|
123
|
+
}, {
|
|
124
|
+
readonly name: "request_id";
|
|
125
|
+
readonly icebergType: "string";
|
|
126
|
+
readonly required: false;
|
|
127
|
+
}, {
|
|
128
|
+
readonly name: "visitor_key";
|
|
129
|
+
readonly icebergType: "string";
|
|
130
|
+
readonly required: false;
|
|
131
|
+
}, {
|
|
132
|
+
readonly name: "is_bot";
|
|
133
|
+
readonly icebergType: "boolean";
|
|
134
|
+
readonly required: false;
|
|
135
|
+
}];
|
|
136
|
+
/** Every valid `page_views` column name, derived from the table above. */
|
|
137
|
+
export type PageViewColumnName = (typeof PAGE_VIEWS_COLUMNS)[number]['name'];
|
|
138
|
+
/** `page_views` is partitioned by this column. */
|
|
139
|
+
export declare const PAGE_VIEWS_PARTITION_COLUMN: PageViewColumnName;
|
|
140
|
+
/** Maps an `IcebergType` to the TypeScript type `PageView` stores it as. */
|
|
141
|
+
type TsTypeOf<T extends IcebergType> = T extends 'string' | 'timestamp' | 'date' ? string : T extends 'int' | 'long' | 'double' ? number : T extends 'boolean' ? boolean : never;
|
|
142
|
+
type RequiredColumn = Extract<(typeof PAGE_VIEWS_COLUMNS)[number], {
|
|
143
|
+
required: true;
|
|
144
|
+
}>;
|
|
145
|
+
type OptionalColumn = Extract<(typeof PAGE_VIEWS_COLUMNS)[number], {
|
|
146
|
+
required: false;
|
|
147
|
+
}>;
|
|
148
|
+
/**
|
|
149
|
+
* One `page_views` row, derived from `PAGE_VIEWS_COLUMNS` rather than
|
|
150
|
+
* hand-typed, so the transform (which builds these) cannot invent a column
|
|
151
|
+
* name the table does not carry - a typo in a property name here is a
|
|
152
|
+
* compile error, not a silently-dropped Firehose field.
|
|
153
|
+
*/
|
|
154
|
+
export type PageView = {
|
|
155
|
+
[C in RequiredColumn as C['name']]: TsTypeOf<C['icebergType']>;
|
|
156
|
+
} & {
|
|
157
|
+
[C in OptionalColumn as C['name']]?: TsTypeOf<C['icebergType']>;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* The CloudFront viewer-IP field. Selected so the transform can derive
|
|
161
|
+
* `visitor_key` from it (IP + user agent + a secret daily salt); the raw
|
|
162
|
+
* value is discarded by the transform and never written to any column, so
|
|
163
|
+
* it has no entry in `FIELD_TO_COLUMN`.
|
|
164
|
+
*/
|
|
165
|
+
export declare const VIEWER_IP_FIELD = "c-ip";
|
|
166
|
+
/**
|
|
167
|
+
* The CloudFront millisecond-epoch timestamp field. Selected so the
|
|
168
|
+
* transform can derive both `event_time` and the `day` partition from it;
|
|
169
|
+
* because it feeds two columns rather than renaming into one, it has no
|
|
170
|
+
* entry in `FIELD_TO_COLUMN` either. Exported for its one consumer,
|
|
171
|
+
* `transform/map-record.ts`, which reads the field off the record and must
|
|
172
|
+
* not spell its name a second time - the field name is not a valid column
|
|
173
|
+
* name, so a divergence between the two spellings would silently stop
|
|
174
|
+
* filling `event_time` and `day`.
|
|
175
|
+
*/
|
|
176
|
+
export declare const TIMESTAMP_MS_FIELD = "timestamp(ms)";
|
|
177
|
+
/**
|
|
178
|
+
* Selected CloudFront fields that feed a `DERIVED_COLUMNS` entry instead of
|
|
179
|
+
* being renamed 1:1 into a column of their own.
|
|
180
|
+
*/
|
|
181
|
+
export declare const DERIVATION_ONLY_FIELDS: readonly ["c-ip", "timestamp(ms)"];
|
|
182
|
+
/**
|
|
183
|
+
* The CloudFront standard-logging (v2) field name each `page_views` column
|
|
184
|
+
* is filled from, a straight rename with no other transformation. Field
|
|
185
|
+
* names and meanings are as AWS documents them under "Standard logging
|
|
186
|
+
* reference":
|
|
187
|
+
*
|
|
188
|
+
* - `x-host-header` (not `cs(Host)`) is the Host header the viewer actually
|
|
189
|
+
* sent - the alternate domain name (CNAME) when the site has one, and the
|
|
190
|
+
* distribution's own domain otherwise. `cs(Host)` always reports the raw
|
|
191
|
+
* CloudFront distribution domain regardless of what the viewer requested,
|
|
192
|
+
* which would make `host` a constant for every custom-domain site.
|
|
193
|
+
* - `x-edge-result-type` (not `x-edge-response-result-type` or
|
|
194
|
+
* `x-edge-detailed-result-type`) is the standard hit/miss/error
|
|
195
|
+
* classification after the response finished sending - the field AWS's own
|
|
196
|
+
* sample cache-hit-ratio queries group by. The other two exist for
|
|
197
|
+
* diagnosing mid-response client disconnects and origin-error detail,
|
|
198
|
+
* which this table does not carry a column for.
|
|
199
|
+
* - `sc-bytes` (not `cs-bytes` or `sc-content-len`) is the total bytes
|
|
200
|
+
* CloudFront sent to the viewer, matching `bytes_sent`; `cs-bytes` is the
|
|
201
|
+
* viewer's request size and `sc-content-len` is only the `Content-Length`
|
|
202
|
+
* header value.
|
|
203
|
+
*/
|
|
204
|
+
export declare const FIELD_TO_COLUMN: {
|
|
205
|
+
readonly 'x-host-header': "host";
|
|
206
|
+
readonly 'cs-uri-stem': "uri";
|
|
207
|
+
readonly 'cs-uri-query': "query";
|
|
208
|
+
readonly 'cs-method': "method";
|
|
209
|
+
readonly 'sc-status': "status";
|
|
210
|
+
readonly 'cs(Referer)': "referrer";
|
|
211
|
+
readonly 'cs(User-Agent)': "user_agent";
|
|
212
|
+
readonly 'c-country': "country";
|
|
213
|
+
readonly asn: "asn";
|
|
214
|
+
readonly 'x-edge-location': "edge_location";
|
|
215
|
+
readonly 'x-edge-result-type': "result_type";
|
|
216
|
+
readonly 'sc-bytes': "bytes_sent";
|
|
217
|
+
readonly 'time-taken': "time_taken";
|
|
218
|
+
readonly 'sc-content-type': "content_type";
|
|
219
|
+
readonly 'cs-protocol': "protocol";
|
|
220
|
+
readonly 'x-edge-request-id': "request_id";
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* The CloudFront fields the analytics delivery selects: every
|
|
224
|
+
* `FIELD_TO_COLUMN` key plus the two derivation-only inputs. `cs(Cookie)`
|
|
225
|
+
* and `x-forwarded-for` are deliberately absent - see the module doc comment.
|
|
226
|
+
*/
|
|
227
|
+
export declare const CLOUDFRONT_RECORD_FIELDS: readonly [...string[], "c-ip", "timestamp(ms)"];
|
|
228
|
+
/**
|
|
229
|
+
* The four `page_views` columns no CloudFront field maps to 1:1 - each is
|
|
230
|
+
* computed by the transform from one or more selected fields rather than
|
|
231
|
+
* renamed from a single one:
|
|
232
|
+
*
|
|
233
|
+
* - `event_time` and `day` are both derived from `timestamp(ms)`.
|
|
234
|
+
* - `visitor_key` is a salted hash of `c-ip`, `cs(User-Agent)` and a secret
|
|
235
|
+
* daily salt - no single field determines it.
|
|
236
|
+
* - `is_bot` is a user-agent match against `cs(User-Agent)`, which already
|
|
237
|
+
* has its own entry in `FIELD_TO_COLUMN` (-> `user_agent`).
|
|
238
|
+
*/
|
|
239
|
+
export declare const DERIVED_COLUMNS: readonly ["event_time", "day", "visitor_key", "is_bot"];
|
|
240
|
+
export {};
|