Skip to content

Commit 70c24a4

Browse files
fix: allowログの決定論的サンプリングを実装 (#259)
* feat(observability): apply deterministic allow sampling * ci(dependabot): defer incompatible toolchain majors * refactor(observability): improve sampling hash distribution
1 parent 9200e78 commit 70c24a4

34 files changed

Lines changed: 901 additions & 28 deletions

File tree

.github/dependabot.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,10 @@ updates:
2727
groups:
2828
dev-dependencies:
2929
dependency-type: "development"
30+
# c8 12 requires Node >=20.19 while this package supports >=20.17.
31+
# TypeScript 7 removes compiler options still required by this project.
32+
ignore:
33+
- dependency-name: "c8"
34+
versions: [">=12.0.0"]
35+
- dependency-name: "typescript"
36+
versions: [">=7.0.0"]

docs/observability.ja.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515

1616
## 構造化 JSON ログ(生成ランタイム)
1717

18-
`observability.log_format: json`(既定)を指定すると、生成された viewer-request / origin-request / Cloudflare Worker は判定 1 件につき 1 行の JSON を `console.log` に出力します。フィールド:
18+
`observability.log_format: json`(既定)を指定すると、生成された viewer-request / origin-request / Cloudflare Worker は拒否・monitor・audit・error の判定を `console.log` に出力します。許可リクエストは `observability.sample_rate` に従って出力します。フィールド:
1919

2020
| フィールド | 説明 ||
2121
|------------|------|-----|
22-
| `ts` | ISO-8601 タイムスタンプ | `2026-04-23T12:34:56.789Z` |
23-
| `level` | block/monitor/audit は `info`、実行エラーは `error` | `info` |
24-
| `event` | `block` / `monitor`(monitor モード)/ `audit` / `error` | `block` |
22+
| `ts` | Unix タイムスタンプ(ミリ秒) | `1776947696789` |
23+
| `level` | block`warn`、allow/monitor/audit は `info`。実行エラーは出力元により `info` または `error` | `warn` |
24+
| `event` | `allow` / `block` / `monitor`(monitor モード)/ `audit` / `error` | `block` |
2525
| `status` | 返した HTTP ステータス | `405` |
2626
| `block_reason` | ブロック理由(下表参照) | `method_not_allowed` |
2727
| `method` | リクエストメソッド | `POST` |
@@ -40,7 +40,7 @@
4040
ブロックイベント例:
4141

4242
```json
43-
{"ts":"2026-04-23T12:34:56.789Z","level":"info","event":"block","status":405,"block_reason":"method_not_allowed","method":"POST","uri":"/anything","correlation_id":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}
43+
{"ts":1776947696789,"level":"warn","event":"block","status":405,"block_reason":"method_not_allowed","method":"POST","uri":"/anything","correlation_id":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}
4444
```
4545

4646
### ポリシー
@@ -49,7 +49,7 @@
4949
observability:
5050
log_format: "json" # "json"(既定)または "text"
5151
correlation_id_header: "traceparent" # もしくは "x-request-id"
52-
sample_rate: 1 # 0..1(現状は advisory — block/audit は常時出力)
52+
sample_rate: 1 # 0..1(許可リクエストを抽出。block/audit は常時出力)
5353
audit_log_auth: true # 認証ゲート成功時に audit イベントを出す
5454
audit_hash_sub: true # sub を SHA-256 先頭 16 hex にハッシュ(PII 対策)
5555
```
@@ -58,6 +58,8 @@ observability:
5858
5959
Lambda@Edge / Worker は受信リクエストに `correlation_id_header` が無いとき自動採番(`crypto.randomUUID` / `crypto.getRandomValues`)して origin への転送ヘッダに付与します。これにより Edge / WAF / Origin のログを同一 ID で串刺しできます。
6060

61+
allow のサンプリング判定は決定論的です。受信時の相関 ID があればそれを使い、無ければリクエストメソッドと URI パスを使います。Lambda@Edge / Worker は相関 ID の自動採番前にこのキーを確定するため、同じ method/path の再試行は同じサンプルバケットに入ります。`sample_rate: 0` は allow ログを無効化し、`1` はすべての許可リクエストを記録します。block / monitor / audit / error はサンプリングしません。
62+
6163
---
6264

6365
## ブロック理由の対応表

docs/observability.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ This document describes recommended **logging and metrics** for the Edge Securit
1515

1616
## Structured JSON Logs (generated runtime)
1717

18-
When `observability.log_format: json` is set (default), the generated viewer-request / origin-request / Cloudflare Worker emit one JSON line per decision to `console.log`. Fields:
18+
When `observability.log_format: json` is set (default), the generated viewer-request / origin-request / Cloudflare Worker emit denied, monitor, audit, and error decisions to `console.log`. Allowed requests are emitted according to `observability.sample_rate`. Fields:
1919

2020
| Field | Description | Example |
2121
|-------|-------------|---------|
22-
| `ts` | ISO-8601 timestamp | `2026-04-23T12:34:56.789Z` |
23-
| `level` | `info` on block/monitor/audit, `error` on runtime error | `info` |
24-
| `event` | `block`, `monitor` (monitor mode), `audit`, `error` | `block` |
22+
| `ts` | Unix timestamp in milliseconds | `1776947696789` |
23+
| `level` | `warn` on block; `info` on allow/monitor/audit; runtime errors use `info` or `error` depending on the emitter | `warn` |
24+
| `event` | `allow`, `block`, `monitor` (monitor mode), `audit`, `error` | `block` |
2525
| `status` | HTTP status returned | `405` |
2626
| `block_reason` | Why the request was blocked (see mapping below) | `method_not_allowed` |
2727
| `method` | Request method | `POST` |
@@ -40,7 +40,7 @@ Audit events (`audit_log_auth: true`) add:
4040
Example block event:
4141

4242
```json
43-
{"ts":"2026-04-23T12:34:56.789Z","level":"info","event":"block","status":405,"block_reason":"method_not_allowed","method":"POST","uri":"/anything","correlation_id":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}
43+
{"ts":1776947696789,"level":"warn","event":"block","status":405,"block_reason":"method_not_allowed","method":"POST","uri":"/anything","correlation_id":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}
4444
```
4545

4646
### Policy
@@ -49,7 +49,7 @@ Example block event:
4949
observability:
5050
log_format: "json" # "json" (default) or "text"
5151
correlation_id_header: "traceparent" # or "x-request-id"
52-
sample_rate: 1 # 0..1; currently advisory (block/audit always emit)
52+
sample_rate: 1 # 0..1; sample allowed requests (block/audit always emit)
5353
audit_log_auth: true # emit audit events on auth gate success
5454
audit_hash_sub: true # SHA-256 truncate sub to 16 hex (PII-safe)
5555
```
@@ -58,6 +58,8 @@ observability:
5858
5959
At Lambda@Edge / Worker, if the incoming request does **not** carry `correlation_id_header`, the runtime mints one (`crypto.randomUUID` / `crypto.getRandomValues`) and sets it on the forwarded request. Downstream services then see a consistent ID across edge logs, WAF logs, and origin logs.
6060

61+
Allow sampling is deterministic. The runtime uses the incoming correlation ID when present, otherwise it uses the request method and URI path. Lambda@Edge and Workers capture that key before minting a missing correlation ID, so retries for the same method/path remain in the same sample bucket. `sample_rate: 0` disables allow logs; `1` emits every allowed request. Block, monitor, audit, and error logs are never sampled.
62+
6163
---
6264

6365
## Block Reason Mapping

src/scripts/cloudflare-runtime-tests.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,119 @@ response_headers:
703703
assert.strictEqual(fetchCalls.length, 1, 'report mode should forward GraphQL violations');
704704
});
705705

706+
test('cloudflare sample_rate 1 emits an allow decision', async () => {
707+
const generated = compileCloudflare(`
708+
version: 1
709+
project: cf-allow-sampling-test
710+
request:
711+
allow_methods: ["GET"]
712+
response_headers:
713+
hsts: "max-age=31536000"
714+
observability:
715+
log_format: json
716+
correlation_id_header: traceparent
717+
sample_rate: 1
718+
`);
719+
const captured: string[] = [];
720+
const originalLog = console.log;
721+
console.log = (line: any) => { captured.push(String(line)); };
722+
try {
723+
const request = new Request('https://edge.example.com/sampled', {
724+
method: 'GET',
725+
headers: { 'user-agent': 'runtime-test', traceparent: '00-cf-allow-test-01' },
726+
});
727+
const { res, fetchCalls } = await runGeneratedWorkerRequest(generated, request);
728+
assert.strictEqual(res.status, 200);
729+
assert.strictEqual(fetchCalls.length, 1);
730+
} finally {
731+
console.log = originalLog;
732+
}
733+
const allowLine = captured.find((line) => line.includes('"event":"allow"'));
734+
assert.ok(allowLine, 'expected a sampled allow JSON log; got: ' + captured.join('\n'));
735+
const record = JSON.parse(allowLine as string);
736+
assert.strictEqual(record.method, 'GET');
737+
assert.strictEqual(record.uri, '/sampled');
738+
assert.strictEqual(record.correlation_id, '00-cf-allow-test-01');
739+
});
740+
741+
test('cloudflare sample_rate 0 suppresses allow logs but preserves block logs', async () => {
742+
const generated = compileCloudflare(`
743+
version: 1
744+
project: cf-zero-sampling-test
745+
request:
746+
allow_methods: ["GET"]
747+
response_headers:
748+
hsts: "max-age=31536000"
749+
observability:
750+
log_format: json
751+
correlation_id_header: traceparent
752+
sample_rate: 0
753+
`);
754+
const captured: string[] = [];
755+
const originalLog = console.log;
756+
console.log = (line: any) => { captured.push(String(line)); };
757+
try {
758+
await runGeneratedWorkerRequest(generated, new Request('https://edge.example.com/allowed', {
759+
method: 'GET', headers: { 'user-agent': 'runtime-test' },
760+
}));
761+
const blocked = await runGeneratedWorkerRequest(generated, new Request('https://edge.example.com/blocked', {
762+
method: 'POST', headers: { 'user-agent': 'runtime-test' },
763+
}));
764+
assert.strictEqual(blocked.res.status, 405);
765+
assert.strictEqual(blocked.fetchCalls.length, 0);
766+
} finally {
767+
console.log = originalLog;
768+
}
769+
assert.strictEqual(captured.filter((line) => line.includes('"event":"allow"')).length, 0);
770+
assert.strictEqual(captured.filter((line) => line.includes('"event":"block"')).length, 1);
771+
});
772+
773+
test('cloudflare sampling is deterministic before correlation ID minting', async () => {
774+
const generated = compileCloudflare(`
775+
version: 1
776+
project: cf-stable-sampling-test
777+
request:
778+
allow_methods: ["GET"]
779+
response_headers:
780+
hsts: "max-age=31536000"
781+
observability:
782+
log_format: json
783+
correlation_id_header: traceparent
784+
sample_rate: 0.5
785+
`);
786+
const originalCrypto = globalThis.crypto;
787+
let sequence = 0;
788+
Object.defineProperty(globalThis, 'crypto', {
789+
configurable: true,
790+
value: {
791+
...originalCrypto,
792+
subtle: originalCrypto.subtle,
793+
getRandomValues(bytes: Uint8Array) {
794+
bytes.fill(++sequence % 2 === 0 ? 0x05 : 0x12);
795+
return bytes;
796+
},
797+
},
798+
});
799+
const captured: string[] = [];
800+
const originalLog = console.log;
801+
console.log = (line: any) => { captured.push(String(line)); };
802+
try {
803+
for (let i = 0; i < 8; i++) {
804+
const request = new Request('https://edge.example.com/stable-path', {
805+
method: 'GET',
806+
headers: { 'user-agent': 'runtime-test' },
807+
});
808+
await runGeneratedWorkerRequest(generated, request);
809+
}
810+
} finally {
811+
console.log = originalLog;
812+
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: originalCrypto });
813+
}
814+
const allowCount = captured.filter((line) => line.includes('"event":"allow"')).length;
815+
assert.ok(allowCount === 0 || allowCount === 8,
816+
'minted correlation IDs changed the sampling bucket; allow logs=' + allowCount);
817+
});
818+
706819
test('cloudflare compile fails when allowed_algorithms includes an alg the verifier cannot validate', () => {
707820
let caught: any;
708821
try {

src/scripts/compile-unit-tests.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,6 +1917,68 @@ test('viewer-request: structured JSON block log includes status, block_reason, u
19171917
}
19181918
});
19191919

1920+
test('viewer-request: allow sampling respects sample_rate without suppressing blocks', () => {
1921+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'compile-unit-obs-allow-'));
1922+
try {
1923+
build({
1924+
version: 1,
1925+
request: { allow_methods: ['GET'] },
1926+
response_headers: {},
1927+
observability: { log_format: 'json', correlation_id_header: 'traceparent', sample_rate: 1 },
1928+
routes: [],
1929+
}, { outDir: tmpDir, allowPlaceholderToken: true });
1930+
const code = fs.readFileSync(path.join(tmpDir, 'edge', 'viewer-request.js'), 'utf8');
1931+
const captured: string[] = [];
1932+
const origLog = console.log;
1933+
console.log = (line: any) => { captured.push(String(line)); };
1934+
try {
1935+
const handler = eval('(function(){' + code + '; return handler;})()');
1936+
handler({
1937+
request: {
1938+
method: 'GET',
1939+
uri: '/sampled',
1940+
querystring: '',
1941+
headers: {
1942+
'user-agent': { value: 'runtime-test' },
1943+
'traceparent': { value: '00-viewer-allow-test-01' },
1944+
},
1945+
},
1946+
});
1947+
} finally {
1948+
console.log = origLog;
1949+
}
1950+
1951+
const allowLine = captured.find((line: string) => line.includes('"event":"allow"'));
1952+
assert.ok(allowLine, 'expected a sampled allow JSON log; got: ' + captured.join('\n'));
1953+
const parsed = JSON.parse(allowLine as string);
1954+
assert.strictEqual(parsed.method, 'GET');
1955+
assert.strictEqual(parsed.uri, '/sampled');
1956+
assert.strictEqual(parsed.correlation_id, '00-viewer-allow-test-01');
1957+
1958+
build({
1959+
version: 1,
1960+
request: { allow_methods: ['GET'] },
1961+
response_headers: {},
1962+
observability: { log_format: 'json', correlation_id_header: 'traceparent', sample_rate: 0 },
1963+
routes: [],
1964+
}, { outDir: tmpDir, allowPlaceholderToken: true });
1965+
const zeroCode = fs.readFileSync(path.join(tmpDir, 'edge', 'viewer-request.js'), 'utf8');
1966+
const zeroLogs: string[] = [];
1967+
console.log = (line: any) => { zeroLogs.push(String(line)); };
1968+
try {
1969+
const zeroHandler = eval('(function(){' + zeroCode + '; return handler;})()');
1970+
zeroHandler({ request: { method: 'GET', uri: '/allowed', querystring: '', headers: { 'user-agent': { value: 'runtime-test' } } } });
1971+
zeroHandler({ request: { method: 'POST', uri: '/blocked', querystring: '', headers: { 'user-agent': { value: 'runtime-test' } } } });
1972+
} finally {
1973+
console.log = origLog;
1974+
}
1975+
assert.strictEqual(zeroLogs.filter((line) => line.includes('"event":"allow"')).length, 0);
1976+
assert.strictEqual(zeroLogs.filter((line) => line.includes('"event":"block"')).length, 1);
1977+
} finally {
1978+
fs.rmSync(tmpDir, { recursive: true, force: true });
1979+
}
1980+
});
1981+
19201982
test('validateOriginAuth warns when secret_env is unset (non-strict)', () => {
19211983
const policy = { origin: { auth: { type: 'custom_header', header: 'X-Origin-Verify', secret_env: 'NONEXISTENT_FOR_TEST' } } };
19221984
const warnings: string[] = [];

src/scripts/lib/compile-core.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,11 @@ function build(policy: any, options: any = {}) {
538538
'/* {{FEATURE_VIEWER_AUTH}} */ true',
539539
String(authGates.some((gate) => gate.type === 'static_token' || gate.type === 'basic_auth')),
540540
);
541+
code = injectTemplateCode(
542+
code,
543+
'/* {{FEATURE_ALLOW_SAMPLING}} */ true',
544+
String(requestBase.obs.sampleRate > 0),
545+
);
541546
assertInjectedConstDeclarations(code, ['CFG']);
542547

543548
const distDir = path.join(outDir, 'edge');

0 commit comments

Comments
 (0)