Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | 2x 2x 2x 2x 2x 2x 2x 26x 26x 26x 26x 26x 26x 26x 26x 2x 2x 26x 26x 26x 26x 26x 26x 26x 7x 19x 3x 16x 26x | import { Context, MiddlewareHandler, Next } from 'hono'
import { Logger, Time } from '@bayudwiyansatria/core'
import { events } from '@/constants/Events'
import { AnalyticsService } from '@/core/services/AnalyticsService'
import { CloudflareRequestMetadata } from '@/core/services/CloudflareRequestMetadata'
/**
* Telemetry sink for request metrics.
*
* Stateless — the dataset is read from `env` per call — so one instance is
* enough.
*/
const analytics = new AnalyticsService()
/**
* Reader for the facts Cloudflare attaches to a request.
*
* Stateless, so one instance is enough. Swapping this line is what it would take
* to run this middleware somewhere else — everything below reads
* {@link RequestFacts}, not headers.
*/
const metadata = new CloudflareRequestMetadata()
/**
* Wraps every request and, when it finishes, emits the two records a request
* should leave behind — deliberately in two different places:
*
* - **One log line** to Workers Logs, via `Logger`, under
* `request.completed` or `request.failed`. Level follows the outcome: `5xx`
* and thrown errors log at `error`, `4xx` at `warn`, everything else at
* `info`.
* - **One metric** to Analytics Engine, via
* [`AnalyticsService`](../core/services/AnalyticsService.ts): duration plus a
* unit count, grouped by path with method, status, colo, and country as
* dimensions — the shape rate, latency, and error-rate queries need.
*
* Analytics Engine samples at volume, which is right for aggregates and wrong
* for individual lines, so nothing that must be read back verbatim goes there.
*
* @remarks
* Register this **before** any middleware that can short-circuit — Hono runs
* middleware in registration order, so a logger behind the API-key check would
* never see a `401`.
*
* ## What the line carries
*
* `requestId`, `method`, `path`, `ip`, `colo`, `country`, `status` and
* `durationMs` are offered as **context**, and `LOG_FIELDS` decides which of
* them are ingested. The default in `cloudflareDefaults` keeps the first four
* and the status, because Cloudflare already records the rest on its own
* `$metadata` envelope and a Worker gains nothing by paying for a second copy
* of a column the dashboard renders anyway.
*
* That default is right only while the lines stay inside Workers Logs. A
* deployment shipping through Logpush, exporting OTel, or writing to a file
* should add `service` — a record that only means something inside one vendor's
* console is not a record — and one chasing a regional fault should add `colo`
* and `country`.
*
* What is never restated is anything this middleware would have to compute
* twice, or that says nothing about the request: the CPU and wall time, the
* outcome, the script version.
*
* The request-scoped logger and its request id are stored on the context as
* `logger` and `requestId`, so a handler can log with the same correlation:
*
* ```ts
* ctx.get('logger').warn('authentication.failed', { reason })
* ```
*
* That id survives a Service Binding hop only if the caller sends it on. See
* [`RequestCorrelation`](../core/observability/RequestCorrelation.ts).
*
* @param c The Hono request context.
* @param next Passes control to the next middleware.
* @returns Nothing — the two records are written after `next()` settles.
*
* @function
*
* @author Bayu Dwiyan Satria
* @version 1.0.0
* @since 1.0.0
*/
export const logToAnalytics: MiddlewareHandler = async (ctx: Context, next: Next): Promise<void> => {
const start = Time.now()
const facts = metadata.read(ctx.req.raw)
/*
* Everything ambient goes in the context rather than in the payload below,
* because the context is what `LOG_FIELDS` governs. A deployment that does
* not want the point of presence on every line says so in `wrangler.json`;
* one investigating where its traffic lands turns it back on. Passing these
* as payload would put the answer beyond a deployment's reach and hard-code
* one for every Worker.
*
* `colo` and `country` are read once here from `request.cf`, so the
* per-request cost is a single read rather than a lookup per consumer. They
* go to Analytics Engine below as well, for an unrelated reason — an
* aggregate is joined to nothing and has to carry its own dimensions.
*/
const logger = Logger.fromEnv(ctx.env, {
requestId: facts.requestId,
method: ctx.req.method,
path: ctx.req.path,
ip: facts.clientIp,
colo: facts.colo,
country: facts.country
})
ctx.set('requestId', facts.requestId)
ctx.set('clientIp', facts.clientIp)
ctx.set('logger', logger)
let thrown: unknown
try {
await next()
} catch (e) {
thrown = e
throw e
} finally {
const status = thrown ? 500 : ctx.res ? ctx.res.status : 0
const durationMs = Time.since(start)
const failed = status >= 500 || Boolean(thrown)
/*
* The sentence is what a log viewer shows by default, and it is the only
* part of the line most people ever read. It restates the method, path and
* status that are already fields, deliberately: you read the message, you
* filter on the fields.
*/
const message = `${failed ? 'Request failed' : 'Request completed'}: ${ctx.req.method} ${ctx.req.path} (${status})`
/*
* The cause is payload rather than context, so it survives whatever a
* deployment has done to `LOG_FIELDS`. A failure that does not say why is
* not worth writing.
*/
const detail = thrown ? { error: thrown } : undefined
/*
* Two events, not three. The level distinguishes a `4xx` from a `2xx`
* because both are a request that ended as the application meant it to; a
* `5xx` is a different kind of occurrence, and separating it is what makes
* "how often is this Worker failing" a filter on one value.
*/
const line = logger.child({ status, durationMs })
if (failed) {
line.error(events.REQUEST_FAILED, detail, message)
} else if (status >= 400) {
line.warn(events.REQUEST_COMPLETED, detail, message)
} else {
line.info(events.REQUEST_COMPLETED, detail, message)
}
analytics.request(ctx.env, {
route: ctx.req.path,
method: ctx.req.method,
status,
durationMs,
colo: facts.colo,
country: facts.country
})
}
}
|