Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions .beads/sync_base.jsonl

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'jsdom',
testMatch: ['**/tests/unit/**/*.test.[jt]s', '**/tests/validate-story/**/*.test.[jt]s', '**/tests/replay/**/*.spec.[jt]s'],
testMatch: ['**/tests/unit/**/*.test.[jt]s', '**/tests/validate-story/**/*.test.[jt]s', '**/tests/replay/**/*.spec.[jt]s', '**/tests/integration/**/*.test.[jt]s'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
118 changes: 66 additions & 52 deletions server/telemetry/README.md
Original file line number Diff line number Diff line change
@@ -1,52 +1,66 @@
Telemetry Receiver Prototype

Purpose:

This receiver is a development prototype for collecting telemetry events emitted by the Director and related runtime components. It is intended for local testing and experimentation only — not for production use. Use it to:

- Capture and inspect `director_decision` events emitted by the Director during playtests.
- Exercise telemetry payload shapes and validate downstream processing or analysis scripts.
- Provide a simple, disposable storage backend (newline-delimited JSON) for quick local debugging.

Do not rely on this receiver for production telemetry: it has no authentication, no retention/rotation, and minimal error handling.

Run locally:

- Node (>= 14) is required
- Start the receiver:

PORT=4005 node server/telemetry/receiver.js

It listens on `/` for HTTP POST JSON payloads.

Accepted events:

Only events with `type: "director_decision"` (or `event_type` or nested `event.type`) are accepted and persisted to `server/telemetry/events.ndjson`.

Expected payload shape (example):

{
"type": "director_decision",
"decision": "accept",
"reason": "low_risk",
"meta": { "user": "test" }
}

Example curl test:

curl -v -X POST \
-H "Content-Type: application/json" \
-d '{"type":"director_decision","decision":"accept","meta":{"user":"test"}}' \
http://localhost:4005/

Expected responses:
- 200 {"ok":true} for valid director_decision events
- 400 {"error":"Invalid or unsupported event type"} for invalid event types
- 400 {"error":"Invalid JSON"} for malformed JSON
- 404 for non-POST or other paths

Storage:
- Events are appended to `server/telemetry/events.ndjson` as newline-delimited JSON lines with a `received_at` timestamp.

Notes / next steps:
- This is intentionally minimal. For follow-up work consider adding SQLite persistence, simple schema validation, or basic authentication before using in shared environments.
Telemetry receiver (dev prototype)

Purpose
-------
Lightweight development receiver that accepts POSTed JSON events and persists director decision telemetry for local analysis.

What it does
------------
- Accepts POST requests to `/` with a JSON body.
- Validates that the event represents a `director_decision` (accepts payloads with `type: "director_decision"` or same under `event_type` or `event.type`).
- Appends accepted events as NDJSON lines to `server/telemetry/events.ndjson` (dev ingestion store).

Run locally
-----------
```bash
# starts the receiver on port 4005 by default
node server/telemetry/receiver.js

# to choose a different port (useful in tests):
PORT=0 node server/telemetry/receiver.js
```

The process prints the listening URL to stdout when ready, e.g. `Telemetry receiver listening on http://localhost:4005/`.

API (single endpoint)
---------------------
- POST /
- Content-Type: application/json
- Body: arbitrary JSON representing an event
- Success (200): when the payload identifies as a `director_decision` and was persisted
- Client error (400): when payload is invalid JSON or not a supported event type
- Server error (500): when writing to storage failed

Example payload (director_decision)
----------------------------------
```json
{
"type": "director_decision",
"proposal_id": "p1",
"decision": "approve",
"riskScore": 0.12,
"reason": "low_risk",
"metrics": { "latencyMs": 120 }
}
```

Curl example
------------
```bash
curl -X POST http://localhost:4005/ \
-H 'Content-Type: application/json' \
-d '{"type":"director_decision","proposal_id":"p1","decision":"approve","riskScore":0.12}'
```

Inspecting persisted events
---------------------------
Events are appended to `server/telemetry/events.ndjson` as one JSON object per line. To inspect recent events:

```bash
tail -n 50 server/telemetry/events.ndjson | jq .
```

Development notes
-----------------
- This receiver is intentionally small and intended for dev/testing only. Production work (SQLite storage, schema validation, auth/token protection, log rotation) is tracked in `ge-apq.1` and should be implemented before using this in production.
- The receiver uses `server/telemetry/backend-ndjson.js` as the storage backend; swap or extend backends as needed.
24 changes: 24 additions & 0 deletions server/telemetry/backend-ndjson.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict'

const fs = require('fs')
const path = require('path')

const LOG_DIR = path.join(__dirname)
const LOG_FILE = path.join(LOG_DIR, 'events.ndjson')

function ensureDir() {
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true })
}

function emit(event) {
try {
ensureDir()
// If event looks like a wrapped { received_at, payload } keep that shape
if (event && event.received_at && event.payload) fs.appendFileSync(LOG_FILE, JSON.stringify(event) + '\n', 'utf8')
else fs.appendFileSync(LOG_FILE, JSON.stringify({ received_at: new Date().toISOString(), payload: event }) + '\n', 'utf8')
} catch (e) {
console.error('ndjson backend write failed', e)
}
}

module.exports = { emit }
23 changes: 11 additions & 12 deletions server/telemetry/receiver.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const ndjsonBackend = require('../telemetry/backend-ndjson')

const PORT = process.env.PORT ? Number(process.env.PORT) : 4005;
const DATA_DIR = path.resolve(__dirname);
Expand Down Expand Up @@ -45,21 +46,19 @@ const server = http.createServer((req, res) => {
return;
}

const line = JSON.stringify({ received_at: new Date().toISOString(), payload });

fs.appendFile(OUTFILE, line + '\n', (err) => {
if (err) {
console.error('Failed to persist event', err);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Failed to persist event' }));
return;
}

const event = { received_at: new Date().toISOString(), payload };
// write via simple ndjson backend (appends to events.ndjson)
try {
ndjsonBackend.emit(event);
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ ok: true }));
});
} catch (err) {
console.error('Failed to persist event', err);
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Failed to persist event' }));
}
});

req.on('error', (err) => {
Expand Down
31 changes: 16 additions & 15 deletions src/runtime/subscribers/telemetry.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
// Telemetry subscriber for runtime HookManager
// Emits console-based telemetry events; in prod this should hook into telemetry module

module.exports = function createTelemetrySubscriber(telemetry = console) {
const { defaultTelemetry } = require('../../telemetry/emitter');

// Map runtime hook names to telemetry event types
const HOOK_EVENT_MAP = {
pre_inject: 'generation',
post_inject: 'presentation',
pre_checkpoint: 'validation',
post_checkpoint: 'outcome'
};

module.exports = function createTelemetrySubscriber(telemetryBackend) {
const telemetry = telemetryBackend || defaultTelemetry;
return {
name: 'runtime-telemetry-subscriber',
async pre_inject(payload) {
try {
telemetry.log('telemetry.event', { event: 'pre_inject', payload });
} catch (err) {
// swallow
}
try { telemetry.emit(HOOK_EVENT_MAP.pre_inject, payload); } catch (err) { }
},
async post_inject(payload) {
try {
telemetry.log('telemetry.event', { event: 'post_inject', payload });
} catch (err) {}
try { telemetry.emit(HOOK_EVENT_MAP.post_inject, payload); } catch (err) { }
},
async pre_checkpoint(payload) {
try {
telemetry.log('telemetry.event', { event: 'pre_checkpoint', payload });
} catch (err) {}
try { telemetry.emit(HOOK_EVENT_MAP.pre_checkpoint, payload); } catch (err) { }
},
async post_checkpoint(payload) {
try {
telemetry.log('telemetry.event', { event: 'post_checkpoint', payload });
} catch (err) {}
try { telemetry.emit(HOOK_EVENT_MAP.post_checkpoint, payload); } catch (err) { }
}
};
};
20 changes: 20 additions & 0 deletions src/telemetry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Telemetry module

This lightweight telemetry module provides:

- `src/telemetry/emitter.js` — in-memory telemetry emitter with redact-on-ingest and a simple query API for tests and local analysis.
- `src/telemetry/redact.js` — minimal PII redaction helpers.
- `src/telemetry/backends/console.js` — default backend writing concise logs to console.

Usage (node):

```js
const { defaultTelemetry } = require('./src/telemetry/emitter')
const consoleBackend = require('./src/telemetry/backends/console')
defaultTelemetry.addBackend(consoleBackend)
defaultTelemetry.emit('story_start', { sessionId: 's1', userEmail: 'bob@example.com' })
```

Notes
- Redaction is intentionally conservative; extend `redact.js` for stricter rules.
- Buffer size defaults to 1000 events; override via `new Telemetry({bufferSize})` if needed.
12 changes: 12 additions & 0 deletions src/telemetry/backends/console.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict'

function emit(event) {
// keep a concise log format
try {
console.log('[TELEMETRY]', event.type, event.timestamp, JSON.stringify(event.payload))
} catch (e) {
console.log('[TELEMETRY]', event.type, event.timestamp)
}
}

module.exports = { emit }
60 changes: 60 additions & 0 deletions src/telemetry/emitter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Telemetry emitter: emits events to available backends and provides an in-memory/queryable store for tests.
'use strict'

const { redact } = require('./redact')

const DEFAULT_BUFFER_SIZE = 1000

class Telemetry {
constructor(opts = {}) {
this.bufferSize = opts.bufferSize || DEFAULT_BUFFER_SIZE
this.events = [] // circular buffer
this.backends = []
}

addBackend(backend) {
if (backend && typeof backend.emit === 'function') this.backends.push(backend)
}

emit(type, payload = {}) {
const ts = new Date().toISOString()
const redacted = redact(payload)
const event = { type, timestamp: ts, payload: redacted }
// validate against schema if available
try {
const { validate } = require('./schema')
const res = validate(type, redacted)
if (!res.valid) {
// emit a validation event instead of storing the invalid payload
const v = { type: 'validation', timestamp: ts, payload: { valid: false, errors: res.errors, originalType: type } }
this._push(v)
for (const b of this.backends) { try { b.emit(v) } catch (e) {} }
return
}
} catch (e) {
// ignore schema failures
}

this._push(event)
for (const b of this.backends) {
try { b.emit(event) } catch (e) { console.error('telemetry backend emit failed', e) }
}
}

_push(event) {
this.events.push(event)
if (this.events.length > this.bufferSize) this.events.shift()
}

query(filterFn) {
if (!filterFn) return this.events.slice()
return this.events.filter(filterFn)
}

clear() { this.events = [] }
}

// Singleton for browser/demo usage
const defaultTelemetry = new Telemetry()

module.exports = { Telemetry, defaultTelemetry }
37 changes: 37 additions & 0 deletions src/telemetry/redact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Minimal PII redaction utilities used by the telemetry emitter.
'use strict'

const PII_KEY_RE = /(email|name|ssn|phone|address|credit|card|cc|token)/i
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i

function isPlainObject(v) {
return v && typeof v === 'object' && !Array.isArray(v)
}

function redactValue(v) {
if (typeof v !== 'string') return v
if (EMAIL_RE.test(v)) return '[REDACTED_EMAIL]'
// crude phone/credit detection
if (/\b\d{3}[- ]?\d{2,4}[- ]?\d{2,4}\b/.test(v)) return '[REDACTED]'
return v
}

function redact(obj) {
if (Array.isArray(obj)) return obj.map(redact)
if (!isPlainObject(obj)) return redactValue(obj)

const out = {}
for (const k of Object.keys(obj)) {
const v = obj[k]
if (PII_KEY_RE.test(k)) {
out[k] = typeof v === 'string' ? redactValue(v) : '[REDACTED]'
continue
}
if (Array.isArray(v)) out[k] = v.map(item => (isPlainObject(item) ? redact(item) : redactValue(item)))
else if (isPlainObject(v)) out[k] = redact(v)
else out[k] = redactValue(v)
}
return out
}

module.exports = { redact }
Loading