chore: import logging service

This commit is contained in:
2026-07-14 00:12:51 -07:00
commit a9a2abbb47
54 changed files with 18955 additions and 0 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
# The collector image installs @logger/collector from the Verdaccio registry,
# so it needs none of the source tree in the build context.
**/node_modules/
**/dist/
**/*.tsbuildinfo
.git/
.gitignore
.dockerignore
docs/
skills/
packages/*/src/
packages/*/tsconfig.json
packages/*/.npmignore
*.md
pnpm-lock.yaml
pnpm-workspace.yaml
vitest.config.ts
tsconfig.base.json
Dockerfile

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# docker compose overrides for the @logger/collector service.
# Copy to .env (which is gitignored) and edit. Every key has a safe default in
# docker-compose.yml, so an empty .env is fine.
# Published @logger/collector version to install from Verdaccio at build time.
# Must match a version actually on the registry (./scripts/publish.sh <version>).
COLLECTOR_VERSION=0.2.2
# Verdaccio URL reachable from inside the BUILD container.
# - Docker Desktop (mac/win): host.docker.internal resolves to the host.
# - Linux: build with `--add-host=host.docker.internal:host-gateway`, or point
# this at the host's LAN IP, e.g. http://192.168.0.15:4873
REGISTRY=http://host.docker.internal:4873
# Host port to publish the collector's :4319 on.
COLLECTOR_HOST_PORT=4319

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules/
dist/
packages/*/dist/
# Legacy local log directory (pre-.logging migration).
.logger/
# .logging/config.json is meant to be committed. Ignore runtime logs and any
# secret-bearing local files so they never enter version control.
.logging/logs.jsonl
.logging/worker-token.local
**/.logging/worker-token.local
.logging/*.local.json
.logging/*.secret.json
.logging/tokens*
*.tsbuildinfo
# Local compose overrides. .env.example is committable; .env is not (it may
# hold registry hosts/ports specific to this machine).
.env
.env.*
!.env.example

18
.logging/config.json Normal file
View File

@@ -0,0 +1,18 @@
{
"version": 1,
"systemName": "logger-system",
"server": {
"baseUrl": "https://cfw-gateway.bowong.cc",
"queryPath": "/api/logging/query",
"ingestPath": "/api/logging/ingest"
},
"defaults": {
"app": "logger-system",
"limit": 20
},
"output": {
"format": "compact",
"redact": true,
"maxLines": 200
}
}

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Collector server image. Installs the published @logger/collector from the
# private Verdaccio registry and runs its `logging-collector` server entry.
#
# docker build \
# --build-arg REGISTRY=http://host.docker.internal:4873 \
# -t logger-collector:0.1.0 .
#
# docker run -d -p 4319:4319 -v logger-collector-data:/data logger-collector:0.1.0
#
# Verdaccio allows anonymous reads, so no auth token is needed to install.
FROM node:22-alpine
ARG REGISTRY=http://host.docker.internal:4873
ARG COLLECTOR_VERSION=0.1.0
WORKDIR /app
RUN npm config set registry "${REGISTRY}" \
&& npm install --omit=dev --no-save --no-package-lock "@logger/collector@${COLLECTOR_VERSION}" \
&& npm cache clean --force
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4319 \
LOG_FILE=/data/logs.jsonl
VOLUME ["/data"]
EXPOSE 4319
# Liveness probe: the read-only /query endpoint must answer 200.
HEALTHCHECK --interval=30s --timeout=3s --start-period=3s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT}/query?limit=1" || exit 1
CMD ["node", "/app/node_modules/@logger/collector/dist/server.js"]

249
README.md Normal file
View File

@@ -0,0 +1,249 @@
# Logger System
Independent TypeScript logger collection system with:
- `@logger/sdk`: application logger API and transports.
- `@logger/collector`: JSONL store, HTTP ingest, and read-only HTTP query.
- `@logger/cli`: `log tail` / `log config` for reading records, driven by a `.logging/config.json` profile.
- `logging-agent` skill: a usage contract for Codex / Claude Code to read logs safely.
## Install
```bash
pnpm install
pnpm test
pnpm build
```
## SDK
```ts
import {createLogger, HttpTransport} from '@logger/sdk';
const log = createLogger({
app: 'shop',
service: 'orders',
scope: 'orders.refund',
runtime: 'server',
transport: new HttpTransport('http://127.0.0.1:4319/ingest'),
});
log.info('hello');
log.error('error msg', new Error('payment timeout'), {orderId: 'o-2099'});
await log.flush();
```
Records are structured JSON with `timestamp`, `level`, `message`, `app`, `service`, `scope`, `runtime`, optional `attributes`, optional serialized `error`, and `version: 1`.
Sensitive fields are redacted by default when passed in attributes:
- `password`
- `token`
- `secret`
- `apiKey`
- `authorization`
- `cookie`
- `set-cookie`
## Collector
Use `JsonlLogStore` directly:
```ts
import {JsonlLogStore} from '@logger/collector';
const store = new JsonlLogStore('.logging/logs.jsonl');
await store.append(record);
const latest = await store.tail({limit: 10, level: 'error'});
```
Start an HTTP collector server from application code:
```ts
import {createHttpCollectorServer, JsonlLogStore} from '@logger/collector';
const store = new JsonlLogStore('.logging/logs.jsonl');
createHttpCollectorServer(store).listen(4319);
```
Endpoints:
- `POST /ingest` — append a single `LogRecord` or an array of `LogRecord` objects.
- `GET /query` — read-only tail. Supports query params `level`, `app`, `service`, `scope`, `since`, `limit`; returns a JSON array of matching records (last `limit`, file order).
## Configuration (`.logging/config.json`)
The CLI is driven by a project-local profile at `.logging/config.json`. It declares the system name, where logs live, optional remote access, and safe defaults. Secrets are never stored in the file — only the name of the environment variable that holds a token.
```json
{
"version": 1,
"systemName": "shop",
"local": {"file": ".logging/logs.jsonl"},
"server": {"baseUrl": "https://logs.example.com", "queryPath": "/query", "ingestPath": "/ingest"},
"auth": {"type": "bearer", "tokenEnv": "LOGGING_TOKEN"},
"defaults": {"app": "shop", "service": "orders", "limit": 50},
"output": {"format": "compact", "redact": true, "maxLines": 200}
}
```
Required: `version` (must be `1`) and `systemName`. Everything else is optional. See `skills/logging-agent/references/config-contract.md` for the full field reference and security rules.
### Bootstrapping a profile
```bash
log config init --system=shop
log config init --system=shop --server=https://logs.example.com --token-env=LOGGING_TOKEN
```
`config init` writes `.logging/config.json` plus a `.logging/.gitignore` that ignores `*.local.json`, `*.secret.json`, `tokens*`, and `logs.jsonl` while keeping `config.json` committable. Pass `--force` to overwrite an existing profile.
### Checking a profile
```bash
log config doctor
```
Validates the config shape, confirms `systemName`, checks that any required token environment variable is set (without printing its value), probes remote reachability when a server is configured, and checks the local file. Exits non-zero if any hard check fails.
## CLI
After build:
```bash
node packages/cli/dist/index.js tail
node packages/cli/dist/index.js tail --level=error --app=shop --json
node packages/cli/dist/index.js tail --remote --json
```
The package binary name is `log`:
```bash
log tail
log tail --level=error --app=shop --service=orders --scope=orders.refund --json
log config init --system=shop
log config doctor
```
### Where `tail` reads from
`tail` resolves its source predictably and never silently calls a remote service:
1. `--remote` → query the configured `server` (requires `server.baseUrl`; auth resolved from `auth`).
2. `--file=<path>` or `LOGGER_FILE` → read that local JSONL file.
3. `config.local.file` → read the configured local file.
4. `config.server` configured with no local source → query the server.
5. fallback → read `.logging/logs.jsonl` and print a hint to run `log config init`.
### Filter and output resolution
Command-line flags override `config.defaults`, which override the built-in defaults. Supported filters: `--level`, `--app`, `--service`, `--scope`, `--since`, `-n`/`--limit`. Output is capped to `config.output.maxLines` (default `200`); `--json` emits one record per line; `output.redact` (default `true`) masks known sensitive fields as a safety net even for records that were not redacted at ingest.
Missing config, missing token, or a missing query endpoint each produce a clear error message instead of a silent guess.
## Publishing (private Verdaccio)
Build first, then publish with the helper script:
```bash
pnpm build
./scripts/publish.sh # version defaults to packages/shared-schema's version
./scripts/publish.sh 0.2.2 # or pass an explicit version
```
Use the script instead of `pnpm publish -r`. Verdaccio v6 does **not** extract README from the tarball — it only reads the manifest `readme` field, which modern `npm`/`pnpm publish` no longer populate, so a plain publish makes the registry UI show "No README data found!". The script injects `readme` into each published manifest (rewriting `workspace:*` to the real version on the way, then restoring `package.json`).
The registry at `http://192.168.0.15:4873` is the same Verdaccio as `http://localhost:4873`; anonymous reads are allowed, only publishing needs an `_authToken`. Verify a publish with `curl -s http://192.168.0.15:4873/@logger/cli | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log('readme len:',(JSON.parse(s).readme||'').length))"`.
## Docker (collector server)
The `Dockerfile` builds an image that installs the published `@logger/collector` from Verdaccio and runs its `logging-collector` server entry:
```bash
docker build -t logger-collector:0.1.0 .
docker run -d --name logger-collector -p 4319:4319 -v logger-collector-data:/data logger-collector:0.1.0
```
Configure with env vars `PORT` (default `4319`), `HOST` (default `0.0.0.0`), `LOG_FILE` (default `/data/logs.jsonl`). Endpoints: `POST /ingest`, `GET /query`. Logs persist to the mounted volume.
The build reads from `http://host.docker.internal:4873` by default (Docker Desktop's alias for the host, where the Verdaccio port is published). Override with `--build-arg REGISTRY=http://192.168.0.15:4873` if your Docker network reaches the registry directly.
End-to-end (SDK writes, CLI reads), from a consumer project:
```bash
npm install @logger/sdk @logger/cli --registry=http://192.168.0.15:4873
# .logging/config.json -> { "server": { "baseUrl": "http://127.0.0.1:4319", "queryPath": "/query" }, "auth": {"type":"none"} }
npx log tail --remote --json --level=error
```
## Cloudflare Worker + D1 (remote collector)
`packages/worker` is the Cloudflare-native collector. It exposes the same HTTP surface as the Node collector, but stores records in D1:
- `GET /healthz` — unauthenticated liveness check.
- `POST /ingest` — authenticated ingest for one `LogRecord` or an array of records.
- `GET /query` — authenticated read-only tail query with `level`, `app`, `service`, `scope`, `since`, and `limit`.
The Worker uses D1 binding `DB` and Wrangler secret `LOGGING_TOKEN`. Do not put the token in `wrangler.jsonc`.
```bash
cd packages/worker
pnpm wrangler d1 create logger-collector
# copy the returned database_id into packages/worker/wrangler.jsonc
pnpm wrangler d1 migrations apply logger-collector --remote
pnpm wrangler secret put LOGGING_TOKEN
pnpm wrangler deploy
```
Consumer `.logging/config.json`:
```json
{
"version": 1,
"systemName": "shop",
"server": {
"baseUrl": "https://logger-collector.<account>.workers.dev",
"queryPath": "/query",
"ingestPath": "/ingest"
},
"auth": {"type": "bearer", "tokenEnv": "LOGGING_TOKEN"},
"defaults": {"app": "shop", "limit": 50},
"output": {"format": "compact", "redact": true, "maxLines": 200}
}
```
SDK usage:
```ts
import {createLogger, HttpTransport} from '@logger/sdk';
const log = createLogger({
app: 'shop',
scope: 'orders',
runtime: 'worker',
transport: new HttpTransport('https://logger-collector.<account>.workers.dev/ingest', {
headers: {authorization: `Bearer ${process.env.LOGGING_TOKEN}`},
}),
});
```
## Current Boundaries
Included:
- TypeScript SDK.
- Console, memory, batch, and HTTP transports.
- JSONL append-only store.
- HTTP ingest and read-only HTTP query endpoints.
- Cloudflare Worker remote collector backed by D1.
- CLI driven by `.logging/config.json`: `tail`, `config init`, `config doctor`, local and remote reads, redaction, and `maxLines` safety cap.
- `logging-agent` skill for Codex / Claude Code.
Not included yet:
- UI console.
- Multi-tenant access control (the Worker first version uses one shared Bearer token).
- Full-text search index.
- Alerting.
- Metrics and trace collection.
- OpenTelemetry exporter.

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
# docker compose for the @logger/collector server.
#
# Builds the image from ./Dockerfile, which installs @logger/collector from the
# private Verdaccio registry and runs the HTTP ingest/query server on :4319.
#
# docker compose up -d --build # build + start
# docker compose logs -f # tail logs
# docker compose ps # see health (image defines the probe)
# docker compose restart # restart without rebuild
# docker compose down # stop, keep the named data volume
# docker compose down -v # stop AND wipe stored logs
#
# All knobs default sensibly; override any of them from a .env file or the env:
# COLLECTOR_VERSION=0.2.2 \
# REGISTRY=http://host.docker.internal:4873 \
# COLLECTOR_HOST_PORT=4319 \
# docker compose up -d --build
#
# NOTE: COLLECTOR_VERSION must match a package actually published to Verdaccio
# (./scripts/publish.sh <version>). If 0.2.2 isn't published yet, publish first
# or point COLLECTOR_VERSION at whatever is on the registry.
name: logger
services:
collector:
container_name: logger-collector
build:
context: .
dockerfile: Dockerfile
args:
# Verdaccio reachable from inside the BUILD container.
# host.docker.internal resolves to the host on Docker Desktop (mac/win);
# on Linux, build with `--add-host=host.docker.internal:host-gateway`.
REGISTRY: ${REGISTRY:-http://host.docker.internal:4873}
COLLECTOR_VERSION: ${COLLECTOR_VERSION:-0.2.2}
image: logger-collector:${COLLECTOR_VERSION:-0.2.2}
restart: unless-stopped
ports:
- "${COLLECTOR_HOST_PORT:-4319}:4319"
volumes:
- logger-collector-data:/data
environment:
# The image already bakes these in (NODE_ENV/HOST/PORT/LOG_FILE); listed
# here so they can be overridden without rebuilding the image.
NODE_ENV: production
HOST: 0.0.0.0
PORT: "4319"
LOG_FILE: /data/logs.jsonl
# Lets the running container reach host services (e.g. Verdaccio) on Linux;
# a no-op safe-net on Docker Desktop for mac/win.
extra_hosts:
- "host.docker.internal:host-gateway"
# Healthcheck is inherited from the Dockerfile (GET /query?limit=1).
# Add a healthcheck: block here only if you need to override it.
volumes:
logger-collector-data:

View File

@@ -0,0 +1,173 @@
# Standalone Logger Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build an independent TypeScript logger collection system with SDK, collector/store, and CLI `log tail -n=10`.
**Architecture:** Use a pnpm TypeScript workspace with focused packages: `shared-schema` owns records/redaction/error serialization, `sdk` owns logger API and transports, `collector` owns JSONL append/tail and HTTP ingest, and `cli` owns command parsing/output. The first storage backend is append-only JSONL so CLI tail works without a database.
**Tech Stack:** Node.js ESM, TypeScript, Vitest, pnpm workspaces, Node built-in `fs`, `http`, and `readline`.
## Global Constraints
- The system is independent and must not reference `astryx-admin`.
- Public SDK must support `log.info("hello")` and `log.error("error msg", err)`.
- CLI must support `log tail -n=10`.
- First version stores one JSON object per line in `.logger/logs.jsonl`.
- Do not add external runtime dependencies for the core system.
- Tests are required for schema normalization, redaction, SDK behavior, JSONL tail/filtering, HTTP ingest, and CLI output.
---
### Task 1: Workspace And Shared Schema
**Files:**
- Create: `package.json`
- Create: `pnpm-workspace.yaml`
- Create: `tsconfig.base.json`
- Create: `vitest.config.ts`
- Create: `packages/shared-schema/package.json`
- Create: `packages/shared-schema/tsconfig.json`
- Create: `packages/shared-schema/src/index.ts`
- Test: `packages/shared-schema/src/index.test.ts`
**Interfaces:**
- Produces: `LogLevel`, `LogRuntime`, `LogRecord`, `LogRecordInput`, `createLogRecord(input, options)`, `serializeError(error)`, `redactValue(value, keys?)`, `parseLogLine(line)`.
- [ ] **Step 1: Write failing tests**
Create `packages/shared-schema/src/index.test.ts` with tests proving `createLogRecord` fills ids/timestamps/version, error serialization preserves message/name/stack, and redaction masks nested sensitive keys.
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test packages/shared-schema/src/index.test.ts`
Expected: FAIL because package scripts or implementation do not exist yet.
- [ ] **Step 3: Write minimal implementation and workspace config**
Create the workspace config and implement the schema helpers in `packages/shared-schema/src/index.ts`.
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm test packages/shared-schema/src/index.test.ts`
Expected: PASS.
### Task 2: SDK Logger And Transports
**Files:**
- Create: `packages/sdk/package.json`
- Create: `packages/sdk/tsconfig.json`
- Create: `packages/sdk/src/index.ts`
- Test: `packages/sdk/src/index.test.ts`
**Interfaces:**
- Consumes: `createLogRecord`, `LogRecord`, `LogRecordInput` from `@logger/shared-schema`.
- Produces: `createLogger(config)`, `Logger`, `LogTransport`, `ConsoleTransport`, `MemoryTransport`, `BatchTransport`, `HttpTransport`.
- [ ] **Step 1: Write failing tests**
Create tests showing `createLogger({app, service, scope})` supports `info`, `warn`, `debug`, `error`, `child`, and `flush`; `error` serializes `Error`; transport failures do not throw by default; strict mode throws.
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test packages/sdk/src/index.test.ts`
Expected: FAIL because SDK implementation does not exist.
- [ ] **Step 3: Write minimal implementation**
Implement logger methods and transports.
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm test packages/sdk/src/index.test.ts`
Expected: PASS.
### Task 3: Collector JSONL Store And HTTP Ingest
**Files:**
- Create: `packages/collector/package.json`
- Create: `packages/collector/tsconfig.json`
- Create: `packages/collector/src/index.ts`
- Test: `packages/collector/src/index.test.ts`
**Interfaces:**
- Consumes: `LogRecord`, `parseLogLine`, `redactValue` from `@logger/shared-schema`.
- Produces: `JsonlLogStore`, `createHttpCollectorServer`, `LogTailFilter`, `TailOptions`.
- [ ] **Step 1: Write failing tests**
Create tests proving append writes JSONL, tail returns last N records in file order, filters by level/app/service/scope/since, invalid lines are skipped by default and fail in strict mode, and HTTP ingest appends a posted record.
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test packages/collector/src/index.test.ts`
Expected: FAIL because collector implementation does not exist.
- [ ] **Step 3: Write minimal implementation**
Implement JSONL append/tail and HTTP ingest with Node built-ins.
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm test packages/collector/src/index.test.ts`
Expected: PASS.
### Task 4: CLI Tail Command
**Files:**
- Create: `packages/cli/package.json`
- Create: `packages/cli/tsconfig.json`
- Create: `packages/cli/src/index.ts`
- Test: `packages/cli/src/index.test.ts`
**Interfaces:**
- Consumes: `JsonlLogStore` from `@logger/collector`.
- Produces: `runCli(argv, io)`, `parseArgs(argv)`, `formatRecord(record)`.
- [ ] **Step 1: Write failing tests**
Create tests proving `log tail -n=10`, `--level`, `--app`, `--service`, `--scope`, `--json`, and missing file behavior.
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm test packages/cli/src/index.test.ts`
Expected: FAIL because CLI implementation does not exist.
- [ ] **Step 3: Write minimal implementation**
Implement argument parsing, output formatting, and CLI entrypoint.
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm test packages/cli/src/index.test.ts`
Expected: PASS.
### Task 5: Build, Documentation, And Final Verification
**Files:**
- Create: `README.md`
- Modify: `package.json`
**Interfaces:**
- Consumes: all package exports.
- Produces: documented usage for SDK, collector, and CLI.
- [ ] **Step 1: Write README examples**
Document installation, SDK usage, `logger collect`, and `log tail -n=10`.
- [ ] **Step 2: Run full verification**
Run: `pnpm test && pnpm build`
Expected: all tests pass and TypeScript emits package builds.
- [ ] **Step 3: Inspect for forbidden coupling**
Run: `rg -n "astryx|auditLog|OpenLogs|localStorage" .`
Expected: no matches.
## Self-Review
- Spec coverage: SDK, collector, JSONL store, CLI tail, redaction, error serialization, batch/flush, filters, and documentation are covered.
- Placeholder scan: no TODO/TBD placeholders are intentionally left.
- Type consistency: packages use shared `LogRecord` and `LogRecordInput`; SDK transport and collector store both consume `LogRecord`.

View File

@@ -0,0 +1,70 @@
# Cloudflare D1 Collector Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build and deploy a Cloudflare Worker remote collector that stores logger records in D1.
**Architecture:** Add `packages/worker` as a Cloudflare-only package. It reuses `@logger/shared-schema`, validates Bearer auth, writes indexed D1 rows with full `record_json`, and returns `LogRecord[]` from `/query` for CLI compatibility.
**Tech Stack:** TypeScript, pnpm workspace, Vitest, Cloudflare Workers, Wrangler, D1.
## Global Constraints
- Keep the existing Node/Docker JSONL collector unchanged.
- Use D1 binding `DB`.
- Use Wrangler secret `LOGGING_TOKEN`; do not hardcode secrets.
- Keep `/ingest` and `/query` compatible with existing SDK and CLI.
- Use prepared statements for SQL.
- This directory is not a git repository, so commit steps are not executable here.
---
### Task 1: Worker Package and D1 Schema
**Files:**
- Create: `packages/worker/package.json`
- Create: `packages/worker/tsconfig.json`
- Create: `packages/worker/wrangler.jsonc`
- Create: `packages/worker/migrations/0001_create_logs.sql`
- Modify: `package.json`
**Interfaces:**
- Produces package `@logger/worker`.
- Produces D1 table `logs`.
- [x] Add a workspace package for the Worker.
- [x] Add D1 migration with query indexes.
- [x] Add root build and typecheck scripts for the Worker package.
### Task 2: Worker Handler
**Files:**
- Create: `packages/worker/src/index.ts`
- Test: `packages/worker/src/index.test.ts`
**Interfaces:**
- Consumes `parseLogLine` and `LogRecord` from `@logger/shared-schema`.
- Produces `handleRequest(request: Request, env: Env): Promise<Response>`.
- [x] Write tests for auth, ingest, query, and errors.
- [x] Implement bounded request body reading.
- [x] Implement Bearer auth.
- [x] Implement D1 inserts and filtered query.
### Task 3: Docs and Verification
**Files:**
- Modify: `README.md`
**Interfaces:**
- Produces Cloudflare deployment instructions and `.logging/config.json` example.
- [x] Document Worker deployment and consumer config.
- [x] Run `pnpm test`.
- [x] Run `pnpm build`.
- [x] Run `pnpm typecheck`.
- [ ] Generate Wrangler types.
- [ ] Create/apply D1 database.
- [ ] Set `LOGGING_TOKEN` secret.
- [ ] Deploy Worker.
- [ ] Run live smoke test and return the URL.

View File

@@ -0,0 +1,82 @@
# Cloudflare D1 Collector Design
## Goal
Build a Cloudflare Worker backend for the standalone logger system. The Worker is a remote collector that stores and queries log records in D1 while staying compatible with the existing SDK `HttpTransport` and CLI remote query flow.
## Architecture
Add `packages/worker` as a separate Worker package instead of mixing Cloudflare runtime code into the existing Node collector. The existing `packages/collector` remains the Node/Docker JSONL collector. The Worker exposes the same first-version HTTP surface:
- `POST /ingest`
- `GET /query`
- `GET /healthz`
The Worker uses:
- D1 binding `DB` for persistence.
- Wrangler secret `LOGGING_TOKEN` for a shared Bearer token.
- `@logger/shared-schema` for `LogRecord` validation.
- D1 prepared statements for all SQL access.
## D1 Storage
The `logs` table stores indexed query columns plus the complete original record:
- `id`
- `timestamp`
- `observed_timestamp`
- `level`
- `app`
- `service`
- `scope`
- `runtime`
- `trace_id`
- `span_id`
- `session_id`
- `user_id`
- `record_json`
- `created_at`
The first version indexes `timestamp`, `level/app/service/scope`, `trace_id`, `session_id`, and `user_id`. Full-text search, archival, retention jobs, pagination cursors, and multi-token project auth are out of scope.
## Request Flow
`POST /ingest` accepts one `LogRecord` or an array of records. The Worker validates the Bearer token, bounds the request body size, validates every record, and writes the batch to D1. Batch ingest is all-or-nothing at the validation stage: any invalid record returns `400` before database writes are attempted.
`GET /query` validates the token and supports `level`, `app`, `service`, `scope`, `since`, and `limit`. It queries recent matching rows from D1 in descending timestamp order, then reverses the result before returning `LogRecord[]` so CLI output matches tail ordering.
## Error Handling
Unauthorized requests return `401` without revealing whether the token is missing or wrong. Malformed payloads and invalid filters return `400`. Unsupported routes return `404`. Unexpected failures return `500` with a stable generic response body and structured Worker logs for operators.
## Testing
Tests must cover:
- unauthorized requests reject.
- health check succeeds without auth.
- ingest accepts single and batch records.
- ingest rejects invalid records.
- query filters by level, app, service, scope, and since.
- query enforces a safe limit cap.
Final verification must run `pnpm test`, `pnpm build`, `pnpm typecheck`, Wrangler type generation, local D1 migration application where possible, deployment, and a live smoke test against the deployed Worker.
## Deployment
Use Wrangler with a D1 database named `logger-collector`. Store `LOGGING_TOKEN` as a Wrangler secret, not in source or `wrangler.jsonc`. After deploy, configure consumers with:
```json
{
"server": {
"baseUrl": "https://<worker-host>",
"queryPath": "/query",
"ingestPath": "/ingest"
},
"auth": {
"type": "bearer",
"tokenEnv": "LOGGING_TOKEN"
}
}
```

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "logger-system",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc -b packages/shared-schema packages/sdk packages/collector packages/cli packages/worker",
"test": "vitest run",
"typecheck": "tsc -b packages/shared-schema packages/sdk packages/collector packages/cli packages/worker --noEmit"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0",
"vitest": "^4.1.9",
"wrangler": "^4.82.2"
}
}

7
packages/cli/.npmignore Normal file
View File

@@ -0,0 +1,7 @@
# Ship the compiled dist only; keep source, tests, and build config out of the
# published tarball. (.npmignore takes precedence over the root .gitignore, so
# dist/ is included even though the repo .gitignore excludes it.)
src
tsconfig.json
*.tsbuildinfo
dist/**/*.test.*

83
packages/cli/README.md Normal file
View File

@@ -0,0 +1,83 @@
# @logger/cli
The `log` command-line tool for reading logs, driven by a project-local `.logging/config.json` profile.
## Install
```bash
npm install @logger/cli --registry=http://192.168.0.15:4873
```
This installs the `log` binary (and `npx log` works too).
## Commands
```bash
log tail [options]
log config init --system=<name> [--server=<url>] [--token-env=<name>] [--force]
log config doctor
```
### `log tail`
Read recent records. Options:
| Option | Meaning |
| --- | --- |
| `-n`, `--limit=<n>` | number of records (default: `config.defaults.limit` or `20`) |
| `--level=<level>` | `debug` \| `info` \| `warn` \| `error` |
| `--app=<app>` | filter by app |
| `--service=<svc>` | filter by service |
| `--scope=<scope>` | filter by scope |
| `--since=<iso>` | records at or after timestamp |
| `--file=<path>` | read this local JSONL file (overrides config / `LOGGER_FILE`) |
| `--remote` | query the configured server instead of a local file |
| `--json` | emit one JSON record per line |
| `--max-lines=<n>` | cap output lines (default: `config.output.maxLines` or `200`) |
| `--strict` | fail on invalid JSONL lines instead of skipping |
```bash
log tail --level=error --limit=20
log tail --app=shop --service=orders --scope=orders.refund --json
log tail --remote --json --since=2026-07-06T00:00:00Z
```
### Source resolution (`tail`)
Predictable, and never silently hits a remote service:
1. `--remote` → query the configured `server` (requires `server.baseUrl`; auth resolved from `auth`).
2. `--file=<path>` or `LOGGER_FILE` → that local file.
3. `config.local.file` → the configured local file.
4. `config.server` configured with no local source → query the server.
5. fallback → `.logging/logs.jsonl` (with a hint to run `log config init`).
Command-line flags override `config.defaults`, which override built-in defaults. Output is capped to `config.output.maxLines`; `output.redact` (default `true`) masks known sensitive fields as a safety net.
### `log config init`
Bootstraps `.logging/config.json` plus a `.logging/.gitignore` (ignores `*.local.json`, `*.secret.json`, `tokens*`, `logs.jsonl`; keeps `config.json` committable). Use `--server` / `--token-env` to scaffold remote access, `--force` to overwrite.
### `log config doctor`
Validates the config shape, confirms `systemName`, checks any required token env var is set (without printing its value), probes remote reachability when a server is configured, and checks the local file. Exits non-zero on any hard failure.
## Configuration
See the root README's "Configuration (`.logging/config.json`)" section and `skills/logging-agent/references/config-contract.md` for the full field reference. Minimal example:
```json
{
"version": 1,
"systemName": "shop",
"local": {"file": ".logging/logs.jsonl"},
"defaults": {"app": "shop", "limit": 50},
"output": {"format": "compact", "redact": true, "maxLines": 200}
}
```
## Run from the source checkout
```bash
node packages/cli/dist/index.js tail --json --limit=20
```

24
packages/cli/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@logger/cli",
"version": "0.2.2",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"log": "./dist/index.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@logger/collector": "workspace:*",
"@logger/shared-schema": "workspace:*"
},
"scripts": {
"build": "tsc -b",
"test": "vitest run --root ../.. packages/cli/src"
}
}

303
packages/cli/src/config.ts Normal file
View File

@@ -0,0 +1,303 @@
import {readFile} from 'node:fs/promises';
import {join, resolve} from 'node:path';
import type {LogLevel} from '@logger/shared-schema';
/**
* `.logging` config contract.
*
* The config lives at `join(cwd, ".logging", "config.json")` and is the single
* place an AI agent learns how to read a project's logs. It only ever holds
* non-secret values: tokens live in environment variables referenced by name
* via `auth.tokenEnv`.
*/
export type AuthType = 'none' | 'bearer' | 'header';
export type OutputFormat = 'compact' | 'json';
export interface LoggingLocalConfig {
/** Project-relative JSONL file used for local reads. */
file?: string;
}
export interface LoggingServerConfig {
/** Remote logging service origin, e.g. "https://logs.example.com". */
baseUrl: string;
/** Read-only query endpoint path. Defaults to "/query". */
queryPath?: string;
/** Ingest endpoint path. Defaults to "/ingest". */
ingestPath?: string;
}
export interface LoggingAuthConfig {
/** Auth strategy. Defaults to "none" when omitted. */
type?: AuthType;
/** Name of the environment variable holding the token. */
tokenEnv?: string;
/** Header name used when `type === "header"`. */
headerName?: string;
}
export interface LoggingDefaults {
app?: string;
service?: string;
scope?: string;
level?: LogLevel;
limit?: number;
since?: string;
}
export interface LoggingOutputConfig {
format?: OutputFormat;
redact?: boolean;
maxLines?: number;
}
export interface LoggingConfig {
version: number;
systemName: string;
local?: LoggingLocalConfig;
server?: LoggingServerConfig;
auth?: LoggingAuthConfig;
defaults?: LoggingDefaults;
output?: LoggingOutputConfig;
}
export const CONFIG_DIR = '.logging';
export const CONFIG_FILE = 'config.json';
/** Default local JSONL file, project-relative. */
export const DEFAULT_LOCAL_FILE = join(CONFIG_DIR, 'logs.jsonl');
export const DEFAULT_LIMIT = 20;
export const DEFAULT_MAX_LINES = 200;
const CONFIG_VERSION = 1;
export type TailFilter = Pick<LoggingDefaults, 'app' | 'service' | 'scope' | 'level' | 'limit' | 'since'>;
/** Error raised for any malformed or unreadable `.logging/config.json`. */
export class LoggingConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'LoggingConfigError';
}
}
export function resolveConfigPath(cwd: string): string {
return join(cwd, CONFIG_DIR, CONFIG_FILE);
}
/**
* Load and validate `.logging/config.json`.
*
* Returns `undefined` when the file does not exist so callers can fall back to
* local defaults with a clear hint. Any present-but-invalid config throws a
* {@link LoggingConfigError} with an actionable message.
*/
export async function loadConfig(cwd: string): Promise<LoggingConfig | undefined> {
let raw: string;
try {
raw = await readFile(resolveConfigPath(cwd), 'utf8');
} catch (error) {
if (isErrnoException(error) && error.code === 'ENOENT') return undefined;
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new LoggingConfigError(
`.logging/config.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
return validateConfig(parsed);
}
export function validateConfig(value: unknown): LoggingConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LoggingConfigError('.logging/config.json must be a JSON object');
}
const raw = value as Record<string, unknown>;
if (raw.version !== CONFIG_VERSION) {
throw new LoggingConfigError(`.logging/config.json: version must be ${CONFIG_VERSION}`);
}
if (typeof raw.systemName !== 'string' || raw.systemName.trim().length === 0) {
throw new LoggingConfigError('.logging/config.json: systemName is required');
}
if (raw.local !== undefined) {
assertObject(raw.local, 'local');
const local = raw.local as Record<string, unknown>;
if (local.file !== undefined && typeof local.file !== 'string') {
throw new LoggingConfigError('.logging/config.json: local.file must be a string');
}
}
if (raw.server !== undefined) {
assertObject(raw.server, 'server');
const server = raw.server as Record<string, unknown>;
if (typeof server.baseUrl !== 'string' || server.baseUrl.trim().length === 0) {
throw new LoggingConfigError('.logging/config.json: server.baseUrl is required when server is set');
}
if (server.queryPath !== undefined && typeof server.queryPath !== 'string') {
throw new LoggingConfigError('.logging/config.json: server.queryPath must be a string');
}
if (server.ingestPath !== undefined && typeof server.ingestPath !== 'string') {
throw new LoggingConfigError('.logging/config.json: server.ingestPath must be a string');
}
}
if (raw.auth !== undefined) {
assertObject(raw.auth, 'auth');
const auth = raw.auth as Record<string, unknown>;
if (auth.type !== undefined && !['none', 'bearer', 'header'].includes(auth.type as string)) {
throw new LoggingConfigError('.logging/config.json: auth.type must be one of none, bearer, header');
}
if (auth.tokenEnv !== undefined && typeof auth.tokenEnv !== 'string') {
throw new LoggingConfigError('.logging/config.json: auth.tokenEnv must be a string');
}
if (auth.headerName !== undefined && typeof auth.headerName !== 'string') {
throw new LoggingConfigError('.logging/config.json: auth.headerName must be a string');
}
}
if (raw.defaults !== undefined) {
assertObject(raw.defaults, 'defaults');
}
if (raw.output !== undefined) {
assertObject(raw.output, 'output');
const output = raw.output as Record<string, unknown>;
if (output.format !== undefined && !['compact', 'json'].includes(output.format as string)) {
throw new LoggingConfigError('.logging/config.json: output.format must be compact or json');
}
if (output.redact !== undefined && typeof output.redact !== 'boolean') {
throw new LoggingConfigError('.logging/config.json: output.redact must be a boolean');
}
if (output.maxLines !== undefined && (!Number.isInteger(output.maxLines) || (output.maxLines as number) < 1)) {
throw new LoggingConfigError('.logging/config.json: output.maxLines must be a positive integer');
}
}
return raw as unknown as LoggingConfig;
}
function assertObject(value: unknown, field: string): void {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LoggingConfigError(`.logging/config.json: ${field} must be an object`);
}
}
/**
* Resolve where `tail` reads from.
*
* Priority (predictable, never silently hits a remote service):
* 1. `--remote` -> remote (requires `server.baseUrl`)
* 2. `--file`/LOGGER_FILE -> local (explicit)
* 3. `config.local.file` -> local
* 4. `config.server` -> remote (only a server is configured)
* 5. fallback -> local default `.logging/logs.jsonl`
*/
export type ResolvedSource =
| {kind: 'local'; file: string}
| {kind: 'remote'; url: string};
export interface ResolveSourceInput {
cwd: string;
remoteFlag?: boolean;
fileFlag?: string;
loggerFileEnv?: string;
config?: LoggingConfig;
}
export function resolveSource(input: ResolveSourceInput): ResolvedSource {
const {cwd, remoteFlag, fileFlag, loggerFileEnv, config} = input;
if (remoteFlag) {
if (!config?.server?.baseUrl) {
throw new LoggingConfigError('--remote requires server.baseUrl in .logging/config.json');
}
return {kind: 'remote', url: buildQueryUrl(config.server)};
}
const explicitLocal = fileFlag ?? loggerFileEnv;
if (explicitLocal) return {kind: 'local', file: resolve(cwd, explicitLocal)};
if (config?.local?.file) return {kind: 'local', file: resolve(cwd, config.local.file)};
if (config?.server?.baseUrl) return {kind: 'remote', url: buildQueryUrl(config.server)};
return {kind: 'local', file: resolve(cwd, DEFAULT_LOCAL_FILE)};
}
export function buildQueryUrl(server: LoggingServerConfig): string {
const base = server.baseUrl.replace(/\/+$/, '');
const path = server.queryPath ?? '/query';
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${base}${normalizedPath}`;
}
/**
* Resolve auth headers for a remote request from the configured strategy.
*
* Throws a {@link LoggingConfigError} with a clear message when a required
* token environment variable is missing or the strategy is incomplete.
*/
export function resolveAuthHeaders(
auth: LoggingAuthConfig | undefined,
env: NodeJS.ProcessEnv | Record<string, string | undefined>,
): Record<string, string> {
if (!auth || auth.type === undefined || auth.type === 'none') return {};
const tokenEnv = auth.tokenEnv;
if (!tokenEnv) {
throw new LoggingConfigError(`auth.type "${auth.type}" requires auth.tokenEnv in .logging/config.json`);
}
const token = env[tokenEnv];
if (token === undefined || token === '') {
throw new LoggingConfigError(
`auth.tokenEnv "${tokenEnv}" is not set in the environment; set it before running a remote query`,
);
}
if (auth.type === 'bearer') return {authorization: `Bearer ${token}`};
if (auth.type === 'header') {
if (!auth.headerName) {
throw new LoggingConfigError('auth.type "header" requires auth.headerName in .logging/config.json');
}
return {[auth.headerName.toLowerCase()]: token};
}
throw new LoggingConfigError(`unknown auth.type "${auth.type as string}"`);
}
/** Merge CLI flags over config defaults; flags win. */
export function resolveFilter(flags: TailFilter, config?: LoggingConfig): Required<Pick<TailFilter, 'limit'>> & TailFilter {
const defaults = config?.defaults ?? {};
return {
app: flags.app ?? defaults.app,
service: flags.service ?? defaults.service,
scope: flags.scope ?? defaults.scope,
level: flags.level ?? defaults.level,
since: flags.since ?? defaults.since,
limit: flags.limit ?? defaults.limit ?? DEFAULT_LIMIT,
};
}
export function resolveMaxLines(config?: LoggingConfig): number {
return config?.output?.maxLines ?? DEFAULT_MAX_LINES;
}
export function resolveOutputFormat(jsonFlag: boolean | undefined, config?: LoggingConfig): OutputFormat {
if (jsonFlag) return 'json';
return config?.output?.format ?? 'compact';
}
export function resolveRedact(config?: LoggingConfig): boolean {
return config?.output?.redact ?? true;
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}

165
packages/cli/src/doctor.ts Normal file
View File

@@ -0,0 +1,165 @@
import {access} from 'node:fs/promises';
import {resolve} from 'node:path';
import {
buildQueryUrl,
loadConfig,
LoggingConfigError,
resolveAuthHeaders,
resolveSource,
type LoggingConfig,
} from './config.js';
import type {RemoteFetch} from './remote.js';
export interface DoctorCheck {
name: string;
status: 'ok' | 'fail' | 'warn' | 'skip';
detail: string;
}
export interface DoctorOptions {
cwd: string;
env: NodeJS.ProcessEnv | Record<string, string | undefined>;
fetchImpl?: RemoteFetch;
/** Per-request timeout for the reachability probe. */
timeoutMs?: number;
}
export interface DoctorResult {
checks: DoctorCheck[];
exitCode: number;
}
const REACHABILITY_TIMEOUT_MS = 5000;
/**
* Validate the logging setup end to end without printing secrets:
* config presence/shape, systemName, token presence (by name only),
* remote reachability, and the local log file.
*/
export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
const {cwd, env} = options;
const checks: DoctorCheck[] = [];
let config: LoggingConfig | undefined;
try {
config = await loadConfig(cwd);
} catch (error) {
checks.push({
name: 'config',
status: 'fail',
detail: error instanceof LoggingConfigError ? error.message : String(error),
});
checks.push({name: 'systemName', status: 'skip', detail: 'config not readable'});
checks.push({name: 'auth', status: 'skip', detail: 'config not readable'});
checks.push({name: 'remote', status: 'skip', detail: 'config not readable'});
checks.push({name: 'local', status: 'skip', detail: 'config not readable'});
return finish(checks);
}
if (!config) {
checks.push({
name: 'config',
status: 'fail',
detail: `no .logging/config.json found in ${cwd}; run "log config init --system=<name>"`,
});
return finish(checks);
}
checks.push({name: 'config', status: 'ok', detail: `systemName="${config.systemName}"`});
checks.push({name: 'systemName', status: 'ok', detail: config.systemName});
checks.push(checkAuth(config, env));
if (config.server?.baseUrl) {
checks.push(await checkRemote(config, env, options));
} else {
checks.push({name: 'remote', status: 'skip', detail: 'no server configured'});
}
checks.push(await checkLocal(cwd, config));
return finish(checks);
}
function checkAuth(config: LoggingConfig, env: DoctorOptions['env']): DoctorCheck {
if (!config.auth || config.auth.type === undefined || config.auth.type === 'none') {
return {name: 'auth', status: 'ok', detail: 'no auth required'};
}
const tokenEnv = config.auth.tokenEnv;
if (!tokenEnv) {
return {
name: 'auth',
status: 'fail',
detail: `auth.type "${config.auth.type}" is set but auth.tokenEnv is missing`,
};
}
const value = env[tokenEnv];
if (value === undefined || value === '') {
return {
name: 'auth',
status: 'fail',
detail: `token environment variable ${tokenEnv} is not set`,
};
}
return {
name: 'auth',
status: 'ok',
detail: `token resolved from ${tokenEnv} (value hidden)`,
};
}
async function checkRemote(
config: LoggingConfig,
env: DoctorOptions['env'],
options: DoctorOptions,
): Promise<DoctorCheck> {
const url = buildQueryUrl(config.server!);
let headers: Record<string, string> = {};
try {
headers = resolveAuthHeaders(config.auth, env);
} catch (error) {
return {
name: 'remote',
status: 'fail',
detail: `auth not resolvable: ${error instanceof Error ? error.message : String(error)}`,
};
}
const fetchImpl = options.fetchImpl ?? ((u: string, init?: Parameters<RemoteFetch>[1]) => fetch(u, init as RequestInit));
const timeoutMs = options.timeoutMs ?? REACHABILITY_TIMEOUT_MS;
try {
const response = await fetchImpl(url, {method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs)});
if (response.ok) {
return {name: 'remote', status: 'ok', detail: `${url} reachable`};
}
return {name: 'remote', status: 'fail', detail: `${url} returned HTTP ${response.status}`};
} catch (error) {
return {
name: 'remote',
status: 'fail',
detail: `could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function checkLocal(cwd: string, config: LoggingConfig): Promise<DoctorCheck> {
const source = resolveSource({cwd, config});
if (source.kind !== 'local') {
return {name: 'local', status: 'skip', detail: 'remote is the configured source'};
}
try {
await access(source.file);
return {name: 'local', status: 'ok', detail: source.file};
} catch {
return {
name: 'local',
status: 'warn',
detail: `local file ${source.file} does not exist yet (it is created on first write)`,
};
}
}
function finish(checks: DoctorCheck[]): DoctorResult {
const exitCode = checks.some(check => check.status === 'fail') ? 1 : 0;
return {checks, exitCode};
}

View File

@@ -0,0 +1,481 @@
import {mkdtemp, rm, writeFile, mkdir, readFile} from 'node:fs/promises';
import {existsSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join, dirname} from 'node:path';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createHttpCollectorServer, JsonlLogStore} from '@logger/collector';
import {createLogRecord, type LogRecord} from '@logger/shared-schema';
import {formatRecord, parseArgs, runCli} from './index.js';
import {
buildQueryUrl,
loadConfig,
LoggingConfigError,
resolveAuthHeaders,
resolveFilter,
resolveSource,
validateConfig,
} from './config.js';
import {queryRemote} from './remote.js';
import {runDoctor} from './doctor.js';
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'logger-cli-'));
});
afterEach(async () => {
await rm(dir, {force: true, recursive: true});
});
function record(id: string, overrides: Partial<LogRecord> = {}): LogRecord {
return createLogRecord({
app: 'shop',
level: 'info',
message: `message ${id}`,
runtime: 'node',
scope: 'orders',
timestamp: `2026-07-06T16:30:0${id}.000Z`,
...overrides,
}, {
id,
now: () => `2026-07-06T16:30:0${id}.000Z`,
observedNow: () => `2026-07-06T16:30:0${id}.001Z`,
});
}
describe('cli', () => {
test('parses tail arguments', () => {
expect(parseArgs(['tail', '-n=10', '--level=error', '--app=shop', '--service=api', '--scope=orders', '--json']))
.toEqual({
command: 'tail',
json: true,
limit: 10,
level: 'error',
app: 'shop',
service: 'api',
scope: 'orders',
});
});
test('formats a record for human output', () => {
expect(formatRecord(record('1', {level: 'error', attributes: {orderId: 'o-1'}})))
.toBe('2026-07-06T16:30:01.000Z ERROR orders message 1 orderId=o-1');
});
test('tail prints last n records from a JSONL file', async () => {
const file = join(dir, 'logs.jsonl');
const store = new JsonlLogStore(file);
await store.append(record('1'));
await store.append(record('2'));
await store.append(record('3'));
const stdout: string[] = [];
const exitCode = await runCli(['tail', '-n=2', `--file=${file}`], {
stdout: line => stdout.push(line),
stderr: () => undefined,
});
expect(exitCode).toBe(0);
expect(stdout).toEqual([
'2026-07-06T16:30:02.000Z INFO orders message 2',
'2026-07-06T16:30:03.000Z INFO orders message 3',
]);
});
test('tail supports filters and json output', async () => {
const file = join(dir, 'logs.jsonl');
const store = new JsonlLogStore(file);
await store.append(record('1', {level: 'info', app: 'shop', service: 'api', scope: 'orders'}));
await store.append(record('2', {level: 'error', app: 'shop', service: 'worker', scope: 'orders.refund'}));
const stdout: string[] = [];
const exitCode = await runCli([
'tail',
'--json',
'--level=error',
'--app=shop',
'--service=worker',
'--scope=orders.refund',
`--file=${file}`,
], {
stdout: line => stdout.push(line),
stderr: () => undefined,
});
expect(exitCode).toBe(0);
expect(stdout).toHaveLength(1);
expect(JSON.parse(stdout[0])).toMatchObject({id: '2', level: 'error', scope: 'orders.refund'});
});
test('tail returns success and a helpful stderr line when the log file is missing', async () => {
const stderr: string[] = [];
const exitCode = await runCli(['tail', '--file', join(dir, 'missing.jsonl')], {
stdout: () => undefined,
stderr: line => stderr.push(line),
});
expect(exitCode).toBe(0);
expect(stderr).toEqual(['No log records found.']);
});
test('unknown command fails with usage text', async () => {
const stderr: string[] = [];
const exitCode = await runCli(['unknown'], {
stdout: () => undefined,
stderr: line => stderr.push(line),
});
expect(exitCode).toBe(1);
expect(stderr[0]).toContain('Usage: log tail');
});
});
function capture(): {stdout: string[]; stderr: string[]; io: {stdout: (line: string) => void; stderr: (line: string) => void}} {
const stdout: string[] = [];
const stderr: string[] = [];
return {stdout, stderr, io: {stdout: line => stdout.push(line), stderr: line => stderr.push(line)}};
}
async function writeConfig(cwd: string, config: unknown): Promise<void> {
const path = join(cwd, '.logging', 'config.json');
await mkdir(dirname(path), {recursive: true});
await writeFile(path, JSON.stringify(config), 'utf8');
}
async function writeRawLines(file: string, records: LogRecord[]): Promise<void> {
await mkdir(dirname(file), {recursive: true});
await writeFile(file, `${records.map(record => JSON.stringify(record)).join('\n')}\n`, 'utf8');
}
async function withServer(store: JsonlLogStore, run: (port: number) => Promise<void>): Promise<void> {
const server = createHttpCollectorServer(store);
await new Promise<void>(resolve => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('expected tcp server');
try {
await run(address.port);
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
}
describe('config loader', () => {
test('validateConfig accepts a minimal config and rejects bad ones', () => {
expect(validateConfig({version: 1, systemName: 'shop'})).toEqual({version: 1, systemName: 'shop'});
expect(() => validateConfig({version: 1})).toThrow(LoggingConfigError);
expect(() => validateConfig({version: 2, systemName: 'shop'})).toThrow(LoggingConfigError);
expect(() => validateConfig({version: 1, systemName: 'shop', output: {format: 'xml'}})).toThrow(LoggingConfigError);
});
test('loadConfig returns undefined when missing and parses when present', async () => {
expect(await loadConfig(dir)).toBeUndefined();
await writeConfig(dir, {version: 1, systemName: 'shop', defaults: {limit: 7}});
const config = await loadConfig(dir);
expect(config?.systemName).toBe('shop');
expect(config?.defaults?.limit).toBe(7);
});
test('loadConfig throws on invalid json', async () => {
await mkdir(join(dir, '.logging'), {recursive: true});
await writeFile(join(dir, '.logging', 'config.json'), '{not json', 'utf8');
await expect(loadConfig(dir)).rejects.toThrow(LoggingConfigError);
});
test('resolveSource priority is predictable', () => {
const withServer = {version: 1, systemName: 's', server: {baseUrl: 'https://logs.example.com', queryPath: '/query'}};
expect(() => resolveSource({cwd: dir, remoteFlag: true})).toThrow(LoggingConfigError);
expect(resolveSource({cwd: dir, remoteFlag: true, config: withServer})).toEqual({
kind: 'remote',
url: 'https://logs.example.com/query',
});
expect(resolveSource({cwd: dir, fileFlag: 'custom.jsonl', config: {version: 1, systemName: 's', local: {file: '.logging/logs.jsonl'}}})).toEqual({
kind: 'local',
file: join(dir, 'custom.jsonl'),
});
expect(resolveSource({cwd: dir, loggerFileEnv: '/abs/logs.jsonl'})).toEqual({kind: 'local', file: '/abs/logs.jsonl'});
expect(resolveSource({cwd: dir, config: {version: 1, systemName: 's', local: {file: '.logging/logs.jsonl'}}})).toEqual({
kind: 'local',
file: join(dir, '.logging', 'logs.jsonl'),
});
expect(resolveSource({cwd: dir, config: withServer})).toEqual({kind: 'remote', url: 'https://logs.example.com/query'});
expect(resolveSource({cwd: dir})).toEqual({kind: 'local', file: join(dir, '.logging', 'logs.jsonl')});
});
test('resolveAuthHeaders handles none, bearer, header, and missing tokens', () => {
expect(resolveAuthHeaders(undefined, {})).toEqual({});
expect(resolveAuthHeaders({type: 'none'}, {})).toEqual({});
expect(resolveAuthHeaders({type: 'bearer', tokenEnv: 'T'}, {T: 'abc'})).toEqual({authorization: 'Bearer abc'});
expect(resolveAuthHeaders({type: 'header', tokenEnv: 'T', headerName: 'X-Api-Key'}, {T: 'k'})).toEqual({'x-api-key': 'k'});
expect(() => resolveAuthHeaders({type: 'bearer'}, {})).toThrow(LoggingConfigError);
expect(() => resolveAuthHeaders({type: 'bearer', tokenEnv: 'T'}, {})).toThrow(LoggingConfigError);
});
test('resolveFilter merges flags over config defaults', () => {
const config = {version: 1, systemName: 's', defaults: {app: 'shop', limit: 5}};
expect(resolveFilter({}, config)).toMatchObject({app: 'shop', limit: 5});
expect(resolveFilter({app: 'billing', limit: 2}, config)).toMatchObject({app: 'billing', limit: 2});
expect(resolveFilter({}, undefined).limit).toBe(20);
});
test('buildQueryUrl normalizes baseUrl and queryPath', () => {
expect(buildQueryUrl({baseUrl: 'https://x.com/', queryPath: '/query'})).toBe('https://x.com/query');
expect(buildQueryUrl({baseUrl: 'https://x.com'})).toBe('https://x.com/query');
expect(buildQueryUrl({baseUrl: 'https://x.com', queryPath: 'q'})).toBe('https://x.com/q');
});
});
describe('cli tail with config', () => {
test('reads the local file and applies config defaults', async () => {
await writeConfig(dir, {version: 1, systemName: 'shop', local: {file: '.logging/logs.jsonl'}, defaults: {app: 'shop', limit: 10}});
const store = new JsonlLogStore(join(dir, '.logging', 'logs.jsonl'));
await store.append(record('1'));
await store.append(record('2', {app: 'billing'}));
await store.append(record('3'));
const {stdout, io} = capture();
const code = await runCli(['tail'], io, {cwd: dir, env: {}});
expect(code).toBe(0);
expect(stdout).toEqual([
'2026-07-06T16:30:01.000Z INFO orders message 1',
'2026-07-06T16:30:03.000Z INFO orders message 3',
]);
});
test('cli flags override config defaults', async () => {
await writeConfig(dir, {version: 1, systemName: 'shop', local: {file: '.logging/logs.jsonl'}, defaults: {app: 'shop'}});
const store = new JsonlLogStore(join(dir, '.logging', 'logs.jsonl'));
await store.append(record('1'));
await store.append(record('2', {app: 'billing'}));
const {stdout, io} = capture();
const code = await runCli(['tail', '--app=billing'], io, {cwd: dir, env: {}});
expect(code).toBe(0);
expect(stdout).toEqual(['2026-07-06T16:30:02.000Z INFO orders message 2']);
});
test('missing config with no --file prints a hint and falls back', async () => {
const {stderr, io} = capture();
const code = await runCli(['tail'], io, {cwd: dir, env: {}});
expect(code).toBe(0);
expect(stderr.some(line => line.includes('No .logging/config.json'))).toBe(true);
expect(stderr.some(line => line.includes('No log records found.'))).toBe(true);
});
test('invalid config exits with a clear error', async () => {
await writeConfig(dir, {version: 1});
const {stderr, io} = capture();
const code = await runCli(['tail'], io, {cwd: dir, env: {}});
expect(code).toBe(1);
expect(stderr.some(line => line.includes('systemName'))).toBe(true);
});
test('output.maxLines caps the number of printed records', async () => {
await writeConfig(dir, {version: 1, systemName: 'shop', local: {file: '.logging/logs.jsonl'}, output: {maxLines: 2}});
const store = new JsonlLogStore(join(dir, '.logging', 'logs.jsonl'));
for (const id of ['1', '2', '3', '4', '5']) await store.append(record(id));
const {stdout, stderr, io} = capture();
const code = await runCli(['tail', '-n=10'], io, {cwd: dir, env: {}});
expect(code).toBe(0);
expect(stdout).toEqual([
'2026-07-06T16:30:04.000Z INFO orders message 4',
'2026-07-06T16:30:05.000Z INFO orders message 5',
]);
expect(stderr.some(line => line.includes('capped to output.maxLines'))).toBe(true);
});
test('output.redact masks sensitive attributes as a safety net', async () => {
await writeConfig(dir, {version: 1, systemName: 'shop', local: {file: '.logging/logs.jsonl'}, output: {redact: true}});
const raw = {...record('1'), attributes: {token: 'supersecret', orderId: 'o-1'}};
await writeRawLines(join(dir, '.logging', 'logs.jsonl'), [raw]);
const {stdout, io} = capture();
await runCli(['tail'], io, {cwd: dir, env: {}});
expect(stdout[0]).toContain('token=[REDACTED]');
expect(stdout[0]).toContain('orderId=o-1');
});
test('output.redact=false leaves attributes visible', async () => {
await writeConfig(dir, {version: 1, systemName: 'shop', local: {file: '.logging/logs.jsonl'}, output: {redact: false}});
const raw = {...record('1'), attributes: {token: 'supersecret'}};
await writeRawLines(join(dir, '.logging', 'logs.jsonl'), [raw]);
const {stdout, io} = capture();
await runCli(['tail'], io, {cwd: dir, env: {}});
expect(stdout[0]).toContain('token=supersecret');
});
});
describe('cli remote query', () => {
test('queries the configured server with bearer auth', async () => {
const store = new JsonlLogStore(join(dir, 'logs.jsonl'));
await store.append(record('1', {level: 'error', app: 'shop', scope: 'orders'}));
await store.append(record('2', {level: 'info', app: 'shop', scope: 'orders'}));
await withServer(store, async port => {
await writeConfig(dir, {
version: 1,
systemName: 'shop',
server: {baseUrl: `http://127.0.0.1:${port}`, queryPath: '/query'},
auth: {type: 'bearer', tokenEnv: 'LOGGING_TOKEN'},
defaults: {level: 'error'},
});
const {stdout, io} = capture();
const code = await runCli(['tail', '--remote', '--json'], io, {cwd: dir, env: {LOGGING_TOKEN: 'secret'}});
expect(code).toBe(0);
expect(stdout).toHaveLength(1);
expect(JSON.parse(stdout[0])).toMatchObject({id: '1', level: 'error'});
});
});
test('missing token produces a clear error and exit 1', async () => {
await withServer(new JsonlLogStore(join(dir, 'logs.jsonl')), async port => {
await writeConfig(dir, {
version: 1,
systemName: 'shop',
server: {baseUrl: `http://127.0.0.1:${port}`},
auth: {type: 'bearer', tokenEnv: 'LOGGING_TOKEN'},
});
const {stderr, io} = capture();
const code = await runCli(['tail', '--remote'], io, {cwd: dir, env: {}});
expect(code).toBe(1);
expect(stderr.some(line => line.includes('LOGGING_TOKEN'))).toBe(true);
});
});
test('--remote without a configured server reports a clear gap', async () => {
await writeConfig(dir, {version: 1, systemName: 'shop', local: {file: '.logging/logs.jsonl'}});
const {stderr, io} = capture();
const code = await runCli(['tail', '--remote'], io, {cwd: dir, env: {}});
expect(code).toBe(1);
expect(stderr.some(line => line.includes('server.baseUrl'))).toBe(true);
});
test('queryRemote parses a json array from the server', async () => {
const store = new JsonlLogStore(join(dir, 'logs.jsonl'));
await store.append(record('1'));
await withServer(store, async port => {
const records = await queryRemote(`http://127.0.0.1:${port}/query`, {}, {limit: 10});
expect(records.map(item => item.id)).toEqual(['1']);
});
});
});
describe('cli config init', () => {
test('writes a minimal config and a .logging/.gitignore', async () => {
const {stdout, io} = capture();
const code = await runCli(['config', 'init', '--system=shop'], io, {cwd: dir, env: {}});
expect(code).toBe(0);
const configPath = join(dir, '.logging', 'config.json');
expect(stdout.some(line => line.includes(`Wrote ${configPath}`))).toBe(true);
const written = JSON.parse(await readFile(configPath, 'utf8'));
expect(written).toMatchObject({
version: 1,
systemName: 'shop',
local: {file: '.logging/logs.jsonl'},
output: {redact: true, maxLines: 200},
});
expect(existsSync(join(dir, '.logging', '.gitignore'))).toBe(true);
});
test('refuses to overwrite without --force', async () => {
await runCli(['config', 'init', '--system=shop'], capture().io, {cwd: dir, env: {}});
const {stderr, io} = capture();
const code = await runCli(['config', 'init', '--system=shop'], io, {cwd: dir, env: {}});
expect(code).toBe(1);
expect(stderr.some(line => line.includes('already exists'))).toBe(true);
});
test('--force overwrites and --server/--token-env scaffold remote config', async () => {
await runCli(['config', 'init', '--system=shop'], capture().io, {cwd: dir, env: {}});
const code = await runCli(
['config', 'init', '--system=shop', '--server', 'https://logs.example.com', '--token-env', 'LOGGING_TOKEN', '--force'],
capture().io,
{cwd: dir, env: {}},
);
expect(code).toBe(0);
const written = JSON.parse(await readFile(join(dir, '.logging', 'config.json'), 'utf8'));
expect(written.server).toEqual({baseUrl: 'https://logs.example.com', queryPath: '/query', ingestPath: '/ingest'});
expect(written.auth).toEqual({type: 'bearer', tokenEnv: 'LOGGING_TOKEN'});
});
test('requires --system', async () => {
const {stderr, io} = capture();
const code = await runCli(['config', 'init'], io, {cwd: dir, env: {}});
expect(code).toBe(1);
expect(stderr.some(line => line.includes('--system'))).toBe(true);
});
});
describe('cli config doctor', () => {
test('reports ok for a healthy local setup', async () => {
await writeConfig(dir, {version: 1, systemName: 'shop', local: {file: '.logging/logs.jsonl'}});
const store = new JsonlLogStore(join(dir, '.logging', 'logs.jsonl'));
await store.append(record('1'));
const {stdout, io} = capture();
const code = await runCli(['config', 'doctor'], io, {cwd: dir, env: {}});
expect(code).toBe(0);
expect(stdout.some(line => line.startsWith('OK') && line.includes('config'))).toBe(true);
});
test('fails when config is missing', async () => {
const {stdout, io} = capture();
const code = await runCli(['config', 'doctor'], io, {cwd: dir, env: {}});
expect(code).toBe(1);
expect(stdout.some(line => line.startsWith('FAIL') && line.includes('config'))).toBe(true);
});
test('fails when a required token env is unset', async () => {
await writeConfig(dir, {
version: 1,
systemName: 'shop',
server: {baseUrl: 'http://127.0.0.1:9'},
auth: {type: 'bearer', tokenEnv: 'LOGGING_TOKEN'},
});
const result = await runDoctor({cwd: dir, env: {}});
expect(result.exitCode).toBe(1);
const auth = result.checks.find(check => check.name === 'auth');
expect(auth?.status).toBe('fail');
expect(auth?.detail).toContain('LOGGING_TOKEN');
});
test('warns but passes when only the local file is missing', async () => {
await writeConfig(dir, {version: 1, systemName: 'shop', local: {file: '.logging/logs.jsonl'}});
const result = await runDoctor({cwd: dir, env: {}});
const local = result.checks.find(check => check.name === 'local');
expect(local?.status).toBe('warn');
expect(result.exitCode).toBe(0);
});
});

388
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,388 @@
#!/usr/bin/env node
import {fileURLToPath} from 'node:url';
import {join, dirname} from 'node:path';
import {existsSync, realpathSync} from 'node:fs';
import {mkdir, writeFile} from 'node:fs/promises';
import {JsonlLogStore} from '@logger/collector';
import {redactValue, type LogLevel, type LogRecord} from '@logger/shared-schema';
import {
DEFAULT_LOCAL_FILE,
loadConfig,
resolveAuthHeaders,
resolveConfigPath,
resolveFilter,
resolveMaxLines,
resolveOutputFormat,
resolveRedact,
resolveSource,
type LoggingConfig,
} from './config.js';
import {queryRemote} from './remote.js';
import {runDoctor} from './doctor.js';
export type CliCommand = 'tail' | 'config-init' | 'config-doctor' | 'help';
export interface CliArgs {
command: CliCommand;
// tail
file?: string;
remote?: boolean;
json?: boolean;
strict?: boolean;
level?: LogLevel;
app?: string;
service?: string;
scope?: string;
since?: string;
limit?: number;
maxLines?: number;
// config init
system?: string;
server?: string;
tokenEnv?: string;
force?: boolean;
}
export interface CliIo {
stdout(line: string): void;
stderr(line: string): void;
}
export type CliEnv = NodeJS.ProcessEnv | Record<string, string | undefined>;
export interface RunCliOptions {
cwd?: string;
env?: CliEnv;
}
const LOG_LEVELS = new Set<LogLevel>(['debug', 'info', 'warn', 'error']);
export function parseArgs(argv: string[]): CliArgs {
const [first = 'help', ...rest] = argv;
if (first === 'config') {
const [sub = 'help', ...subRest] = rest;
if (sub === 'init') return parseConfigInit(subRest);
if (sub === 'doctor') return {command: 'config-doctor'};
return {command: 'help'};
}
if (first === 'help' || first === '--help' || first === '-h') return {command: 'help'};
if (first !== 'tail') return {command: 'help'};
return parseTail(rest);
}
function parseTail(rest: string[]): CliArgs {
const args: CliArgs = {command: 'tail'};
for (let index = 0; index < rest.length; index += 1) {
const token = rest[index];
const {name, inlineValue} = splitToken(token);
const value = inlineValue ?? rest[index + 1];
switch (name) {
case '-n':
case '--limit':
args.limit = parsePositiveInteger(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--max-lines':
case '--maxLines':
args.maxLines = parsePositiveInteger(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--level':
if (!LOG_LEVELS.has(value as LogLevel)) throw new Error(`Invalid --level value: ${value}`);
args.level = value as LogLevel;
if (inlineValue === undefined) index += 1;
break;
case '--app':
args.app = requireValue(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--service':
args.service = requireValue(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--scope':
args.scope = requireValue(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--since':
args.since = requireValue(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--file':
args.file = requireValue(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--remote':
args.remote = true;
break;
case '--json':
args.json = true;
break;
case '--strict':
args.strict = true;
break;
default:
throw new Error(`Unknown option: ${token}`);
}
}
return args;
}
function parseConfigInit(rest: string[]): CliArgs {
const args: CliArgs = {command: 'config-init'};
for (let index = 0; index < rest.length; index += 1) {
const token = rest[index];
const {name, inlineValue} = splitToken(token);
const value = inlineValue ?? rest[index + 1];
switch (name) {
case '--system':
args.system = requireValue(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--server':
args.server = requireValue(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--token-env':
args.tokenEnv = requireValue(value, name);
if (inlineValue === undefined) index += 1;
break;
case '--force':
args.force = true;
break;
default:
throw new Error(`Unknown option: ${token}`);
}
}
return args;
}
function splitToken(token: string): {name: string; inlineValue: string | undefined} {
if (!token.includes('=')) return {name: token, inlineValue: undefined};
const [name, ...rest] = token.split('=');
return {name, inlineValue: rest.join('=')};
}
export async function runCli(argv: string[], io: CliIo = processIo, options: RunCliOptions = {}): Promise<number> {
const cwd = options.cwd ?? process.cwd();
const env = options.env ?? process.env;
let args: CliArgs;
try {
args = parseArgs(argv);
} catch (error) {
io.stderr(error instanceof Error ? error.message : String(error));
io.stderr(usage());
return 1;
}
switch (args.command) {
case 'tail':
return runTail(args, io, cwd, env);
case 'config-init':
return runConfigInit(args, io, cwd);
case 'config-doctor':
return runConfigDoctor(io, cwd, env);
default:
io.stderr(usage());
return 1;
}
}
async function runTail(args: CliArgs, io: CliIo, cwd: string, env: CliEnv): Promise<number> {
let config: LoggingConfig | undefined;
try {
config = await loadConfig(cwd);
} catch (error) {
io.stderr(error instanceof Error ? error.message : String(error));
return 1;
}
if (!config && !args.file && !env.LOGGER_FILE) {
io.stderr(`No .logging/config.json found in ${cwd}.`);
io.stderr(`Run "log config init --system=<name>" to create one; falling back to ${DEFAULT_LOCAL_FILE}.`);
}
const source = tryResolve(io, () =>
resolveSource({cwd, remoteFlag: args.remote, fileFlag: args.file, loggerFileEnv: env.LOGGER_FILE, config}),
);
if (!source) return 1;
const filter = resolveFilter(
{level: args.level, app: args.app, service: args.service, scope: args.scope, since: args.since, limit: args.limit},
config,
);
const maxLines = args.maxLines ?? resolveMaxLines(config);
const format = resolveOutputFormat(args.json, config);
const redact = resolveRedact(config);
if (args.limit !== undefined && args.limit > maxLines) {
io.stderr(`--limit=${args.limit} capped to output.maxLines=${maxLines}.`);
}
const effectiveLimit = Math.min(filter.limit, maxLines);
let records: LogRecord[];
try {
if (source.kind === 'local') {
records = await new JsonlLogStore(source.file).tail({...filter, limit: effectiveLimit, strict: args.strict});
} else {
const headers = resolveAuthHeaders(config?.auth, env);
records = await queryRemote(source.url, headers, {...filter, limit: effectiveLimit});
}
} catch (error) {
io.stderr(error instanceof Error ? error.message : String(error));
return 1;
}
if (records.length === 0) {
io.stderr('No log records found.');
return 0;
}
for (const record of records) {
const safe = redact ? (redactValue(record) as LogRecord) : record;
io.stdout(format === 'json' ? JSON.stringify(safe) : formatRecord(safe));
}
return 0;
}
async function runConfigInit(args: CliArgs, io: CliIo, cwd: string): Promise<number> {
if (!args.system) {
io.stderr('config init requires --system=<name>');
io.stderr(usage());
return 1;
}
const configPath = resolveConfigPath(cwd);
if (existsSync(configPath) && !args.force) {
io.stderr(`${configPath} already exists; pass --force to overwrite.`);
return 1;
}
const config = buildInitialConfig(args);
await mkdir(dirname(configPath), {recursive: true});
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
await ensureLoggingGitignore(cwd);
io.stdout(`Wrote ${configPath}.`);
if (args.server) io.stdout('Server configured. Run "log config doctor" to verify reachability.');
return 0;
}
async function runConfigDoctor(io: CliIo, cwd: string, env: CliEnv): Promise<number> {
const {checks, exitCode} = await runDoctor({cwd, env});
for (const check of checks) {
io.stdout(`${check.status.toUpperCase().padEnd(4)} ${check.name}: ${check.detail}`);
}
return exitCode;
}
function buildInitialConfig(args: CliArgs): LoggingConfig {
const config: LoggingConfig = {
version: 1,
systemName: args.system!,
local: {file: DEFAULT_LOCAL_FILE},
defaults: {limit: 20},
output: {format: 'compact', redact: true, maxLines: 200},
};
if (args.server) {
config.server = {baseUrl: args.server, queryPath: '/query', ingestPath: '/ingest'};
}
if (args.tokenEnv) {
config.auth = {type: 'bearer', tokenEnv: args.tokenEnv};
}
return config;
}
async function ensureLoggingGitignore(cwd: string): Promise<void> {
const gitignorePath = join(cwd, '.logging', '.gitignore');
if (existsSync(gitignorePath)) return;
const patterns = ['# secrets and runtime artifacts; config.json is meant to be committed', '*.local.json', '*.secret.json', 'tokens*', 'logs.jsonl'];
await mkdir(dirname(gitignorePath), {recursive: true});
await writeFile(gitignorePath, `${patterns.join('\n')}\n`, 'utf8');
}
function tryResolve<T>(io: CliIo, fn: () => T): T | undefined {
try {
return fn();
} catch (error) {
io.stderr(error instanceof Error ? error.message : String(error));
return undefined;
}
}
export function formatRecord(record: LogRecord): string {
const attrs = record.attributes
? Object.entries(record.attributes).map(([key, value]) => `${key}=${String(value)}`).join(' ')
: '';
return [
record.timestamp,
record.level.toUpperCase(),
record.scope,
record.message,
attrs,
].filter(Boolean).join(' ');
}
function parsePositiveInteger(value: string | undefined, name: string): number {
const parsed = Number.parseInt(requireValue(value, name), 10);
if (!Number.isFinite(parsed) || parsed < 1) {
throw new Error(`${name} must be a positive integer`);
}
return parsed;
}
function requireValue(value: string | undefined, name: string): string {
if (!value) throw new Error(`${name} requires a value`);
return value;
}
export function usage(): string {
return [
'Usage: log tail [options]',
' log config init --system=<name> [--server=<url>] [--token-env=<name>] [--force]',
' log config doctor',
'',
'tail options:',
' -n, --limit=<n> number of records (default: config.defaults.limit or 20)',
' --level=<level> debug | info | warn | error',
' --app=<app> filter by app',
' --service=<svc> filter by service',
' --scope=<scope> filter by scope',
' --since=<iso> records at or after timestamp',
' --file=<path> local JSONL file (overrides config / LOGGER_FILE)',
' --remote query the configured server instead of a local file',
' --json one JSON record per line',
' --max-lines=<n> cap output lines (default: config.output.maxLines or 200)',
].join('\n');
}
const processIo: CliIo = {
stdout: line => process.stdout.write(`${line}\n`),
stderr: line => process.stderr.write(`${line}\n`),
};
// Resolve symlinks before comparing: when invoked through an npm-installed
// bin (node_modules/.bin/log -> .../dist/index.js), process.argv[1] is the
// symlink path while import.meta.url is the real file path.
function isMainEntry(): boolean {
try {
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
} catch {
return false;
}
}
if (isMainEntry()) {
const code = await runCli(process.argv.slice(2));
process.exitCode = code;
}

View File

@@ -0,0 +1,52 @@
import {parseLogLine, type LogRecord} from '@logger/shared-schema';
import type {TailFilter} from './config.js';
/**
* Minimal fetch surface so the remote client can be tested with an in-process
* collector server (real `fetch`) or a stub.
*/
export interface RemoteFetch {
(
url: string,
init?: {method?: string; headers?: Record<string, string>; signal?: AbortSignal},
): Promise<{ok: boolean; status: number; text(): Promise<string>}>;
}
export const defaultRemoteFetch: RemoteFetch = ((url: string, init?: Parameters<RemoteFetch>[1]) =>
fetch(url, init as RequestInit)) as RemoteFetch;
/**
* Query a remote collector's read-only `/query` endpoint and parse the response
* into validated {@link LogRecord}s.
*/
export async function queryRemote(
url: string,
headers: Record<string, string>,
filter: TailFilter,
fetchImpl: RemoteFetch = defaultRemoteFetch,
): Promise<LogRecord[]> {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter)) {
if (value !== undefined && value !== null) params.set(key, String(value));
}
const fullUrl = params.toString().length > 0 ? `${url}?${params.toString()}` : url;
const response = await fetchImpl(fullUrl, {method: 'GET', headers});
if (!response.ok) {
throw new Error(`remote query to ${url} failed: HTTP ${response.status}`);
}
let payload: unknown;
try {
payload = JSON.parse(await response.text()) as unknown;
} catch (error) {
throw new Error(`remote query to ${url} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
if (!Array.isArray(payload)) {
throw new Error(`remote query to ${url} returned a non-array payload`);
}
return payload.map(item => parseLogLine(JSON.stringify(item)));
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [
{"path": "../shared-schema"},
{"path": "../collector"}
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,7 @@
# Ship the compiled dist only; keep source, tests, and build config out of the
# published tarball. (.npmignore takes precedence over the root .gitignore, so
# dist/ is included even though the repo .gitignore excludes it.)
src
tsconfig.json
*.tsbuildinfo
dist/**/*.test.*

View File

@@ -0,0 +1,70 @@
# @logger/collector
Append-only JSONL log store and an HTTP server with ingest and read-only query. No external runtime dependencies.
## Install
```bash
npm install @logger/collector --registry=http://192.168.0.15:4873
```
## JsonlLogStore
```ts
import {JsonlLogStore} from '@logger/collector';
const store = new JsonlLogStore('.logging/logs.jsonl');
await store.append(record); // one JSON object per line (creates parent dirs)
const latest = await store.tail({ // last N matching records, in file order
limit: 10,
level: 'error',
app: 'shop',
service: 'orders',
scope: 'orders.refund',
since: '2026-07-06T00:00:00.000Z',
strict: false,
});
```
`tail` behavior:
- Returns the last `limit` matching records (default `10`).
- A missing file returns `[]` (handy for fresh projects).
- Invalid JSONL lines are skipped by default; set `strict: true` to throw on the first bad line.
Filters: `level`, `app`, `service`, `scope`, `since` (ISO-8601, inclusive on `timestamp`), plus `limit` and `strict`.
## HTTP server
```ts
import {createHttpCollectorServer, JsonlLogStore} from '@logger/collector';
const server = createHttpCollectorServer(new JsonlLogStore('.logging/logs.jsonl'));
server.listen(4319);
```
Endpoints:
- `POST /ingest` — body is a single `LogRecord` or an array of `LogRecord` objects. Responds `204` on success, `400` on a bad record.
- `GET /query?level=&app=&service=&scope=&since=&limit=` — read-only tail. Returns a JSON array of matching records (same semantics as `store.tail`).
## Standalone server (bin)
The package ships a `logging-collector` binary so the server can run without application code (e.g. inside a container):
```bash
logging-collector # defaults: PORT=4319 LOG_FILE=/data/logs.jsonl HOST=0.0.0.0
PORT=4319 LOG_FILE=./logs.jsonl logging-collector
```
Environment:
| Var | Default | Meaning |
| --- | --- | --- |
| `PORT` | `4319` | TCP port to listen on. |
| `HOST` | `0.0.0.0` | Bind address. |
| `LOG_FILE` | `/data/logs.jsonl` | Backing JSONL file (created on first write). |
`SIGTERM` / `SIGINT` close the server gracefully. For the Docker image, see the root README's "Docker (collector server)" section.

View File

@@ -0,0 +1,23 @@
{
"name": "@logger/collector",
"version": "0.2.2",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"logging-collector": "./dist/server.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@logger/shared-schema": "workspace:*"
},
"scripts": {
"build": "tsc -b",
"test": "vitest run --root ../.. packages/collector/src"
}
}

View File

@@ -0,0 +1,156 @@
import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createLogRecord, type LogRecord} from '@logger/shared-schema';
import {JsonlLogStore, createHttpCollectorServer} from './index.js';
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'logger-collector-'));
});
afterEach(async () => {
await rm(dir, {force: true, recursive: true});
});
function record(id: string, overrides: Partial<LogRecord> = {}): LogRecord {
return createLogRecord({
app: 'shop',
level: 'info',
message: `message ${id}`,
runtime: 'node',
scope: 'orders',
timestamp: `2026-07-06T16:30:0${id}.000Z`,
...overrides,
}, {
id,
now: () => `2026-07-06T16:30:0${id}.000Z`,
observedNow: () => `2026-07-06T16:30:0${id}.001Z`,
});
}
describe('collector jsonl store', () => {
test('append writes one JSON object per line and tail returns the last records in file order', async () => {
const file = join(dir, 'logs.jsonl');
const store = new JsonlLogStore(file);
await store.append(record('1'));
await store.append(record('2'));
await store.append(record('3'));
const raw = await readFile(file, 'utf8');
expect(raw.trim().split('\n')).toHaveLength(3);
expect((await store.tail({limit: 2})).map(item => item.id)).toEqual(['2', '3']);
});
test('tail filters by level app service scope and since', async () => {
const store = new JsonlLogStore(join(dir, 'logs.jsonl'));
await store.append(record('1', {level: 'info', app: 'shop', service: 'api', scope: 'orders'}));
await store.append(record('2', {level: 'error', app: 'shop', service: 'worker', scope: 'orders.refund'}));
await store.append(record('3', {level: 'error', app: 'billing', service: 'api', scope: 'billing.charge'}));
const records = await store.tail({
app: 'shop',
level: 'error',
limit: 10,
service: 'worker',
since: '2026-07-06T16:30:02.000Z',
scope: 'orders.refund',
});
expect(records.map(item => item.id)).toEqual(['2']);
});
test('tail skips invalid JSON lines by default and fails in strict mode', async () => {
const file = join(dir, 'logs.jsonl');
await writeFile(file, `${JSON.stringify(record('1'))}\nnot-json\n${JSON.stringify(record('2'))}\n`);
const store = new JsonlLogStore(file);
expect((await store.tail({limit: 10})).map(item => item.id)).toEqual(['1', '2']);
await expect(store.tail({limit: 10, strict: true})).rejects.toThrow('Invalid JSONL log line');
});
test('tail returns an empty list for a missing file', async () => {
const store = new JsonlLogStore(join(dir, 'missing.jsonl'));
await expect(store.tail({limit: 10})).resolves.toEqual([]);
});
test('http ingest appends a posted record', async () => {
const store = new JsonlLogStore(join(dir, 'logs.jsonl'));
const server = createHttpCollectorServer(store);
await new Promise<void>(resolve => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('expected tcp server');
try {
const response = await fetch(`http://127.0.0.1:${address.port}/ingest`, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify(record('1', {message: 'from http'})),
});
expect(response.status).toBe(204);
expect((await store.tail({limit: 10}))[0].message).toBe('from http');
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
test('http query returns a filtered tail as json', async () => {
const store = new JsonlLogStore(join(dir, 'logs.jsonl'));
await store.append(record('1', {level: 'info', app: 'shop', service: 'api', scope: 'orders'}));
await store.append(record('2', {level: 'error', app: 'shop', service: 'worker', scope: 'orders.refund'}));
await store.append(record('3', {level: 'error', app: 'billing', service: 'api', scope: 'billing.charge'}));
const server = createHttpCollectorServer(store);
await new Promise<void>(resolve => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('expected tcp server');
try {
const response = await fetch(
`http://127.0.0.1:${address.port}/query?level=error&app=shop&service=worker&scope=orders.refund&limit=10`,
);
expect(response.status).toBe(200);
const payload = (await response.json()) as LogRecord[];
expect(payload).toHaveLength(1);
expect(payload[0]).toMatchObject({id: '2', level: 'error', scope: 'orders.refund'});
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
test('http query without filters returns recent records', async () => {
const store = new JsonlLogStore(join(dir, 'logs.jsonl'));
await store.append(record('1'));
await store.append(record('2'));
const server = createHttpCollectorServer(store);
await new Promise<void>(resolve => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('expected tcp server');
try {
const response = await fetch(`http://127.0.0.1:${address.port}/query?limit=10`);
const payload = (await response.json()) as LogRecord[];
expect(payload.map(item => item.id)).toEqual(['1', '2']);
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
test('unknown http route responds 404', async () => {
const store = new JsonlLogStore(join(dir, 'logs.jsonl'));
const server = createHttpCollectorServer(store);
await new Promise<void>(resolve => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('expected tcp server');
try {
const response = await fetch(`http://127.0.0.1:${address.port}/unknown`);
expect(response.status).toBe(404);
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
});

View File

@@ -0,0 +1,129 @@
import {createServer, type IncomingMessage, type Server, type ServerResponse} from 'node:http';
import {mkdir, readFile, appendFile} from 'node:fs/promises';
import {dirname} from 'node:path';
import {parseLogLine, type LogLevel, type LogRecord} from '@logger/shared-schema';
export interface LogTailFilter {
app?: string;
level?: LogLevel;
service?: string;
scope?: string;
since?: string;
}
export interface TailOptions extends LogTailFilter {
limit?: number;
strict?: boolean;
}
export class JsonlLogStore {
constructor(readonly file: string) {}
async append(record: LogRecord): Promise<void> {
await mkdir(dirname(this.file), {recursive: true});
await appendFile(this.file, `${JSON.stringify(record)}\n`, 'utf8');
}
async tail(options: TailOptions = {}): Promise<LogRecord[]> {
const limit = options.limit ?? 10;
let raw: string;
try {
raw = await readFile(this.file, 'utf8');
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') return [];
throw error;
}
const records: LogRecord[] = [];
const lines = raw.split('\n').filter(line => line.trim().length > 0);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
try {
const record = parseLogLine(line);
if (matchesFilter(record, options)) {
records.push(record);
}
} catch (error) {
if (options.strict) {
throw new Error(`Invalid JSONL log line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
return records.slice(Math.max(0, records.length - limit));
}
}
export function createHttpCollectorServer(store: JsonlLogStore): Server {
return createServer(async (request, response) => {
try {
const url = request.url ?? '/';
if (request.method === 'POST' && url === '/ingest') {
const body = await readRequestBody(request);
const payload = JSON.parse(body) as unknown;
const records = Array.isArray(payload) ? payload : [payload];
for (const item of records) {
const record = parseLogLine(JSON.stringify(item));
await store.append(record);
}
response.writeHead(204).end();
return;
}
if (request.method === 'GET' && url.startsWith('/query')) {
const records = await store.tail(parseQuery(url));
sendJson(response, 200, records);
return;
}
send(response, 404, 'not found');
} catch (error) {
send(response, 400, error instanceof Error ? error.message : String(error));
}
});
}
function parseQuery(url: string): TailOptions {
const {searchParams} = new URL(url, 'http://localhost');
const options: TailOptions = {};
if (searchParams.has('level')) options.level = searchParams.get('level') as LogLevel;
if (searchParams.has('app')) options.app = searchParams.get('app') ?? undefined;
if (searchParams.has('service')) options.service = searchParams.get('service') ?? undefined;
if (searchParams.has('scope')) options.scope = searchParams.get('scope') ?? undefined;
if (searchParams.has('since')) options.since = searchParams.get('since') ?? undefined;
if (searchParams.has('limit')) {
const parsed = Number.parseInt(searchParams.get('limit') ?? '', 10);
if (Number.isFinite(parsed) && parsed > 0) options.limit = parsed;
}
return options;
}
function matchesFilter(record: LogRecord, filter: LogTailFilter): boolean {
if (filter.app && record.app !== filter.app) return false;
if (filter.level && record.level !== filter.level) return false;
if (filter.service && record.service !== filter.service) return false;
if (filter.scope && record.scope !== filter.scope) return false;
if (filter.since && record.timestamp < filter.since) return false;
return true;
}
async function readRequestBody(request: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
function send(response: ServerResponse, status: number, message: string): void {
response.writeHead(status, {'content-type': 'text/plain; charset=utf-8'}).end(message);
}
function sendJson(response: ServerResponse, status: number, value: unknown): void {
response.writeHead(status, {'content-type': 'application/json; charset=utf-8'}).end(JSON.stringify(value));
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Standalone collector server entry.
*
* Reads its configuration from the environment so it can run inside a
* container without a project checkout:
*
* PORT TCP port to listen on (default 4319)
* LOG_FILE JSONL file used by the backing store (default /data/logs.jsonl)
* HOST bind address (default 0.0.0.0)
*
* Endpoints: POST /ingest, GET /query (see createHttpCollectorServer).
*/
import {createHttpCollectorServer, JsonlLogStore} from './index.js';
const port = Number(process.env.PORT ?? 4319);
const host = process.env.HOST ?? '0.0.0.0';
const file = process.env.LOG_FILE ?? '/data/logs.jsonl';
const store = new JsonlLogStore(file);
const server = createHttpCollectorServer(store);
server.listen(port, host, () => {
// eslint-disable-next-line no-console
console.log(`[collector] listening on ${host}:${port} (store=${file})`);
});
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => {
server.close(() => process.exit(0));
});
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [
{"path": "../shared-schema"}
],
"include": ["src/**/*.ts"]
}

7
packages/sdk/.npmignore Normal file
View File

@@ -0,0 +1,7 @@
# Ship the compiled dist only; keep source, tests, and build config out of the
# published tarball. (.npmignore takes precedence over the root .gitignore, so
# dist/ is included even though the repo .gitignore excludes it.)
src
tsconfig.json
*.tsbuildinfo
dist/**/*.test.*

66
packages/sdk/README.md Normal file
View File

@@ -0,0 +1,66 @@
# @logger/sdk
Application logger API and transports. Builds on `@logger/shared-schema` to emit normalized, redacted `LogRecord`s.
## Install
```bash
npm install @logger/sdk --registry=http://192.168.0.15:4873
```
## Usage
```ts
import {createLogger, BatchTransport, HttpTransport} from '@logger/sdk';
const log = createLogger({
app: 'shop',
service: 'orders',
scope: 'orders.refund',
runtime: 'server',
transport: new BatchTransport(new HttpTransport('http://127.0.0.1:4319/ingest'), {maxBatchSize: 25}),
});
log.info('order placed', {orderId: 'o-1'});
log.error('payment timeout', new Error('card declined'), {orderId: 'o-2'});
await log.flush(); // flush pending batches / in-flight HTTP writes
```
### Logger methods
```ts
log.debug(message, attributes?)
log.info(message, attributes?)
log.warn(message, attributes?)
log.error(message, error?, attributes?)
log.child(context): Logger // returns a new logger with merged context
await log.flush()
```
`error` serializes the error via `@logger/shared-schema`. Sensitive attribute keys (`password`, `token`, `secret`, `apiKey`, `authorization`, `cookie`, …) are redacted when the record is created.
### Strict mode
By default transport failures are swallowed so logging never crashes the app. Set `strict: true` in the config to make `write` reject (and `flush` throw) on transport errors.
### Context
`createLogger(config)` and `child(context)` merge these fields across the tree: `app`, `service`, `scope`, `attributes`, `runtime`, `traceId`, `spanId`, `sessionId`, `userId`.
## Transports
Each transport implements `write(record)` and an optional `flush()`.
| Transport | Constructor | Behavior |
| --- | --- | --- |
| `ConsoleTransport` | `new ConsoleTransport(consoleLike?)` | Writes `timestamp LEVEL scope message` + attributes JSON to the console. Default target is the global `console`. |
| `MemoryTransport` | `new MemoryTransport()` | Collects records in `.records` (great for tests). |
| `BatchTransport` | `new BatchTransport(target, {maxBatchSize?})` | Buffers records and flushes to `target` when the buffer reaches `maxBatchSize` (default `25`) or on `flush()`. |
| `HttpTransport` | `new HttpTransport(endpoint, {fetch?, headers?})` | `POST`s each record as JSON. Uses global `fetch` unless overridden. Throws on non-2xx. |
Compose them, e.g. batched HTTP delivery:
```ts
new BatchTransport(new HttpTransport('http://127.0.0.1:4319/ingest'), {maxBatchSize: 25});
```

20
packages/sdk/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@logger/sdk",
"version": "0.2.2",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@logger/shared-schema": "workspace:*"
},
"scripts": {
"build": "tsc -b",
"test": "vitest run --root ../.. packages/sdk/src"
}
}

View File

@@ -0,0 +1,129 @@
import {describe, expect, test} from 'vitest';
import {
BatchTransport,
ConsoleTransport,
HttpTransport,
MemoryTransport,
createLogger,
type LogTransport,
} from './index.js';
describe('sdk logger', () => {
test('emits info warn debug and error records through a transport', async () => {
const transport = new MemoryTransport();
const log = createLogger({
app: 'shop',
service: 'orders',
scope: 'orders.refund',
runtime: 'server',
transport,
now: () => '2026-07-06T16:30:00.000Z',
});
log.info('hello', {orderId: 'o-1'});
log.warn('low stock');
log.debug('debugging');
log.error('failed', new Error('boom'), {token: 'secret'});
await log.flush();
expect(transport.records.map(record => record.level)).toEqual(['info', 'warn', 'debug', 'error']);
expect(transport.records[0]).toMatchObject({
app: 'shop',
service: 'orders',
scope: 'orders.refund',
message: 'hello',
attributes: {orderId: 'o-1'},
});
expect(transport.records[3].error?.message).toBe('boom');
expect(transport.records[3].attributes).toEqual({token: '[REDACTED]'});
});
test('child logger merges context without mutating the parent logger', async () => {
const transport = new MemoryTransport();
const parent = createLogger({
app: 'shop',
scope: 'root',
runtime: 'node',
transport,
});
const child = parent.child({service: 'billing', scope: 'billing.charge', attributes: {requestId: 'req-1'}});
child.info('charged', {invoiceId: 'inv-1'});
parent.info('root message');
await parent.flush();
expect(transport.records[0]).toMatchObject({
service: 'billing',
scope: 'billing.charge',
attributes: {requestId: 'req-1', invoiceId: 'inv-1'},
});
expect(transport.records[1]).toMatchObject({scope: 'root'});
expect(transport.records[1].service).toBeUndefined();
});
test('transport failures are swallowed by default and thrown in strict mode', async () => {
const broken: LogTransport = {
write: async () => {
throw new Error('transport down');
},
};
const lenient = createLogger({app: 'shop', scope: 'safe', runtime: 'node', transport: broken});
expect(() => lenient.info('does not throw')).not.toThrow();
const strict = createLogger({app: 'shop', scope: 'strict', runtime: 'node', transport: broken, strict: true});
await expect(strict.info('throws')).rejects.toThrow('transport down');
});
test('batch transport buffers records until flush', async () => {
const memory = new MemoryTransport();
const batch = new BatchTransport(memory, {maxBatchSize: 3});
const log = createLogger({app: 'shop', scope: 'batch', runtime: 'node', transport: batch});
log.info('one');
log.info('two');
expect(memory.records).toHaveLength(0);
log.info('three');
expect(memory.records).toHaveLength(3);
log.info('four');
await log.flush();
expect(memory.records.map(record => record.message)).toEqual(['one', 'two', 'three', 'four']);
});
test('http transport posts one JSON record per write', async () => {
const writes: string[] = [];
const transport = new HttpTransport('http://collector.test/ingest', {
fetch: async (_url, init) => {
writes.push(String(init?.body));
return new Response(null, {status: 204});
},
});
const log = createLogger({app: 'shop', scope: 'http', runtime: 'browser', transport});
log.info('sent');
await log.flush();
expect(JSON.parse(writes[0])).toMatchObject({app: 'shop', scope: 'http', message: 'sent'});
});
test('console transport writes readable level methods', () => {
const calls: string[] = [];
const transport = new ConsoleTransport({
info: (...args: unknown[]) => calls.push(`info:${args.join(' ')}`),
warn: (...args: unknown[]) => calls.push(`warn:${args.join(' ')}`),
error: (...args: unknown[]) => calls.push(`error:${args.join(' ')}`),
debug: (...args: unknown[]) => calls.push(`debug:${args.join(' ')}`),
});
const log = createLogger({app: 'shop', scope: 'console', runtime: 'node', transport});
log.info('hello');
log.error('bad', 'failure');
expect(calls[0]).toContain('info:');
expect(calls[0]).toContain('hello');
expect(calls[1]).toContain('error:');
expect(calls[1]).toContain('bad');
});
});

196
packages/sdk/src/index.ts Normal file
View File

@@ -0,0 +1,196 @@
import {
createLogRecord,
type LogLevel,
type LogRecord,
type LogRecordInput,
type LogRuntime,
} from '@logger/shared-schema';
export type LogAttributes = Record<string, unknown>;
export interface LogContext {
app?: string;
service?: string;
scope?: string;
attributes?: LogAttributes;
runtime?: LogRuntime;
traceId?: string;
spanId?: string;
sessionId?: string;
userId?: string;
}
export interface LogTransport {
write(record: LogRecord): void | Promise<void>;
flush?(): void | Promise<void>;
}
export interface Logger {
debug(message: string, attributes?: LogAttributes): void | Promise<void>;
info(message: string, attributes?: LogAttributes): void | Promise<void>;
warn(message: string, attributes?: LogAttributes): void | Promise<void>;
error(message: string, error?: unknown, attributes?: LogAttributes): void | Promise<void>;
child(context: LogContext): Logger;
flush(): Promise<void>;
}
export interface LoggerConfig extends LogContext {
app: string;
scope: string;
runtime?: LogRuntime;
transport?: LogTransport;
strict?: boolean;
now?: () => string;
}
export function createLogger(config: LoggerConfig): Logger {
const transport: LogTransport = config.transport ?? new ConsoleTransport();
const strict = config.strict ?? false;
const pending = new Set<Promise<void>>();
const context: Required<Pick<LogContext, 'app' | 'scope'>> & LogContext = {
...config,
runtime: config.runtime ?? 'node',
};
function emit(level: LogLevel, message: string, attributes?: LogAttributes, error?: unknown): void | Promise<void> {
const recordInput: LogRecordInput = {
level,
message,
app: context.app,
service: context.service,
scope: context.scope,
attributes: mergeAttributes(context.attributes, attributes),
error,
traceId: context.traceId,
spanId: context.spanId,
sessionId: context.sessionId,
userId: context.userId,
runtime: context.runtime ?? 'node',
};
const record = createLogRecord(recordInput, {now: config.now, observedNow: config.now});
let write: Promise<void>;
try {
write = Promise.resolve(transport.write(record)).then(() => undefined);
} catch (error) {
write = Promise.reject(error);
}
const handled = strict ? write : write.catch(() => undefined);
pending.add(handled);
handled.finally(() => pending.delete(handled)).catch(() => undefined);
return strict ? handled : undefined;
}
const logger: Logger = {
debug: (message, attributes) => emit('debug', message, attributes),
info: (message, attributes) => emit('info', message, attributes),
warn: (message, attributes) => emit('warn', message, attributes),
error: (message, error, attributes) => emit('error', message, attributes, error),
child: childContext => createLogger({
...context,
...childContext,
attributes: mergeAttributes(context.attributes, childContext.attributes),
strict,
transport,
now: config.now,
}),
flush: async () => {
await Promise.all([...pending]);
await transport.flush?.();
},
};
return logger;
}
export class MemoryTransport implements LogTransport {
readonly records: LogRecord[] = [];
write(record: LogRecord): void {
this.records.push(record);
}
}
export interface ConsoleLike {
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
error(...args: unknown[]): void;
debug(...args: unknown[]): void;
}
export class ConsoleTransport implements LogTransport {
constructor(private readonly consoleLike: ConsoleLike = console) {}
write(record: LogRecord): void {
const line = `${record.timestamp} ${record.level.toUpperCase()} ${record.scope} ${record.message}`;
const payload = record.attributes ? JSON.stringify(record.attributes) : '';
this.consoleLike[record.level === 'warn' ? 'warn' : record.level](line, payload);
}
}
export interface BatchTransportOptions {
maxBatchSize?: number;
}
export class BatchTransport implements LogTransport {
private readonly maxBatchSize: number;
private buffer: LogRecord[] = [];
constructor(
private readonly target: LogTransport,
options: BatchTransportOptions = {},
) {
this.maxBatchSize = options.maxBatchSize ?? 25;
}
write(record: LogRecord): Promise<void> | void {
this.buffer.push(record);
if (this.buffer.length >= this.maxBatchSize) {
return this.flush();
}
}
async flush(): Promise<void> {
const batch = this.buffer;
this.buffer = [];
await Promise.all(batch.map(record => Promise.resolve(this.target.write(record))));
await this.target.flush?.();
}
}
export interface HttpTransportOptions {
fetch?: typeof fetch;
headers?: Record<string, string>;
}
export class HttpTransport implements LogTransport {
private readonly fetchImpl: typeof fetch;
private readonly headers: Record<string, string>;
constructor(
private readonly endpoint: string,
options: HttpTransportOptions = {},
) {
this.fetchImpl = options.fetch ?? fetch;
this.headers = options.headers ?? {};
}
async write(record: LogRecord): Promise<void> {
const response = await this.fetchImpl(this.endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...this.headers,
},
body: JSON.stringify(record),
});
if (!response.ok) {
throw new Error(`HTTP log transport failed with status ${response.status}`);
}
}
}
function mergeAttributes(left?: LogAttributes, right?: LogAttributes): LogAttributes | undefined {
if (!left && !right) return undefined;
return {...left, ...right};
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [
{"path": "../shared-schema"}
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,7 @@
# Ship the compiled dist only; keep source, tests, and build config out of the
# published tarball. (.npmignore takes precedence over the root .gitignore, so
# dist/ is included even though the repo .gitignore excludes it.)
src
tsconfig.json
*.tsbuildinfo
dist/**/*.test.*

View File

@@ -0,0 +1,79 @@
# @logger/shared-schema
Structured log record schema: types, normalization, redaction, error serialization, and JSONL line parsing. The shared foundation consumed by `@logger/sdk`, `@logger/collector`, and `@logger/cli`. Zero runtime dependencies.
## Install
```bash
npm install @logger/shared-schema --registry=http://192.168.0.15:4873
```
## LogRecord
A normalized record carries `version: 1`:
```ts
import type {LogRecord, LogLevel, LogRuntime} from '@logger/shared-schema';
const record: LogRecord = {
id: 'uuid',
timestamp: '2026-07-06T16:30:00.000Z',
observedTimestamp: '2026-07-06T16:30:00.001Z',
level: 'error', // 'debug' | 'info' | 'warn' | 'error'
message: 'payment timeout',
app: 'shop',
service: 'orders',
scope: 'orders.refund',
attributes: {orderId: 'o-1'},
error: {name: 'Error', message: 'card declined', stack: '...'},
traceId: '...', spanId: '...', sessionId: '...', userId: '...',
runtime: 'server', // 'browser' | 'node' | 'server' | 'worker' | 'electron' | 'cli'
source: {host: 'host', pid: 1234, file: 'pay.ts', line: 42},
version: 1,
};
```
## API
### `createLogRecord(input, options?)`
Normalizes a `LogRecordInput` into a `LogRecord`: assigns `id`/`timestamp`/`observedTimestamp`, serializes `error`, and redacts `attributes`.
```ts
import {createLogRecord} from '@logger/shared-schema';
const record = createLogRecord({
level: 'error',
message: 'payment timeout',
app: 'shop',
scope: 'orders.refund',
runtime: 'server',
error: new Error('card declined'),
attributes: {orderId: 'o-1', token: 'secret'},
});
// record.attributes.token === '[REDACTED]'
```
`options`: `{ id?, now?, observedNow?, redactKeys? }`. Provide `now`/`observedNow` for deterministic timestamps in tests.
### `serializeError(error)`
Converts any value into a `SerializedError`. Handles `Error` (with `name`/`message`/`stack`/`cause`), strings, and arbitrary objects.
### `redactValue(value, keys?)`
Deep-clones `value` with sensitive keys masked to `'[REDACTED]'` (case-insensitive match). Default keys: `password`, `token`, `secret`, `apiKey`, `api_key`, `authorization`, `cookie`, `set-cookie`. Pass a custom iterable to override.
### `parseLogLine(line)`
Parses one JSONL line into a validated `LogRecord`. Throws if the line is not a valid record.
```ts
import {parseLogLine} from '@logger/shared-schema';
const record = parseLogLine(lineFromJsonlFile);
```
## Notes
- Usually consumed indirectly through `@logger/sdk` (writing) or `@logger/cli` / `@logger/collector` (reading).
- All fields except `id`, `timestamp`, `level`, `message`, `app`, `scope`, `runtime`, `version` are optional.

View File

@@ -0,0 +1,17 @@
{
"name": "@logger/shared-schema",
"version": "0.2.2",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -b",
"test": "vitest run --root ../.. packages/shared-schema/src"
}
}

View File

@@ -0,0 +1,77 @@
import {describe, expect, test} from 'vitest';
import {createLogRecord, parseLogLine, redactValue, serializeError} from './index.js';
describe('shared schema', () => {
test('creates a versioned log record with defaults', () => {
const record = createLogRecord({
app: 'shop',
level: 'info',
message: 'hello',
runtime: 'node',
scope: 'demo',
}, {
id: 'log_test',
now: () => '2026-07-06T16:30:00.000Z',
observedNow: () => '2026-07-06T16:30:00.001Z',
});
expect(record).toMatchObject({
id: 'log_test',
timestamp: '2026-07-06T16:30:00.000Z',
observedTimestamp: '2026-07-06T16:30:00.001Z',
app: 'shop',
level: 'info',
message: 'hello',
runtime: 'node',
scope: 'demo',
version: 1,
});
});
test('serializes errors with name message stack and cause', () => {
const cause = new Error('root cause');
const err = new TypeError('bad input', {cause});
const serialized = serializeError(err);
expect(serialized.name).toBe('TypeError');
expect(serialized.message).toBe('bad input');
expect(serialized.stack).toContain('TypeError: bad input');
expect(serialized.cause).toMatchObject({name: 'Error', message: 'root cause'});
});
test('redacts nested sensitive fields without mutating input', () => {
const input = {
nested: {
authorization: 'Bearer secret',
visible: 'keep',
},
token: 'abc',
list: [{apiKey: 'key'}, {value: 2}],
};
const redacted = redactValue(input);
expect(redacted).toEqual({
nested: {
authorization: '[REDACTED]',
visible: 'keep',
},
token: '[REDACTED]',
list: [{apiKey: '[REDACTED]'}, {value: 2}],
});
expect(input.nested.authorization).toBe('Bearer secret');
});
test('parses one JSONL line into a log record', () => {
const record = createLogRecord({
app: 'shop',
level: 'error',
message: 'failed',
runtime: 'server',
scope: 'orders',
}, {id: 'log_parse'});
expect(parseLogLine(JSON.stringify(record))).toEqual(record);
});
});

View File

@@ -0,0 +1,170 @@
import {randomUUID} from 'node:crypto';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogRuntime = 'browser' | 'node' | 'server' | 'worker' | 'electron' | 'cli';
export interface SerializedError {
name?: string;
message: string;
stack?: string;
cause?: unknown;
}
export interface LogSource {
host?: string;
pid?: number;
file?: string;
line?: number;
}
export interface LogRecord {
id: string;
timestamp: string;
observedTimestamp?: string;
level: LogLevel;
message: string;
app: string;
service?: string;
scope: string;
attributes?: Record<string, unknown>;
error?: SerializedError;
traceId?: string;
spanId?: string;
sessionId?: string;
userId?: string;
runtime: LogRuntime;
source?: LogSource;
version: 1;
}
export interface LogRecordInput {
level: LogLevel;
message: string;
app: string;
service?: string;
scope: string;
attributes?: Record<string, unknown>;
error?: unknown;
traceId?: string;
spanId?: string;
sessionId?: string;
userId?: string;
runtime: LogRuntime;
source?: LogSource;
timestamp?: string;
}
export interface CreateLogRecordOptions {
id?: string;
now?: () => string;
observedNow?: () => string;
redactKeys?: Iterable<string>;
}
const DEFAULT_REDACT_KEYS = new Set([
'password',
'token',
'secret',
'apikey',
'api_key',
'authorization',
'cookie',
'set-cookie',
]);
export function createLogRecord(input: LogRecordInput, options: CreateLogRecordOptions = {}): LogRecord {
const now = options.now ?? (() => new Date().toISOString());
const observedNow = options.observedNow ?? now;
const attributes = input.attributes
? redactValue(input.attributes, options.redactKeys) as Record<string, unknown>
: undefined;
return {
id: options.id ?? randomUUID(),
timestamp: input.timestamp ?? now(),
observedTimestamp: observedNow(),
level: input.level,
message: input.message,
app: input.app,
service: input.service,
scope: input.scope,
attributes,
error: input.error === undefined ? undefined : serializeError(input.error),
traceId: input.traceId,
spanId: input.spanId,
sessionId: input.sessionId,
userId: input.userId,
runtime: input.runtime,
source: input.source,
version: 1,
};
}
export function serializeError(error: unknown): SerializedError {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
cause: error.cause === undefined ? undefined : serializeError(error.cause),
};
}
if (typeof error === 'string') {
return {message: error};
}
return {message: safeStringify(error)};
}
export function redactValue(value: unknown, keys: Iterable<string> = DEFAULT_REDACT_KEYS): unknown {
const redactKeys = new Set([...keys].map(key => key.toLowerCase()));
return redactWalk(value, redactKeys);
}
export function parseLogLine(line: string): LogRecord {
const parsed = JSON.parse(line) as unknown;
if (!isLogRecord(parsed)) {
throw new Error('Invalid log record');
}
return parsed;
}
function redactWalk(value: unknown, redactKeys: Set<string>): unknown {
if (Array.isArray(value)) {
return value.map(item => redactWalk(item, redactKeys));
}
if (!value || typeof value !== 'object') {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
result[key] = redactKeys.has(key.toLowerCase()) ? '[REDACTED]' : redactWalk(child, redactKeys);
}
return result;
}
function isLogRecord(value: unknown): value is LogRecord {
if (!value || typeof value !== 'object') return false;
const record = value as Partial<LogRecord>;
return (
typeof record.id === 'string' &&
typeof record.timestamp === 'string' &&
typeof record.level === 'string' &&
typeof record.message === 'string' &&
typeof record.app === 'string' &&
typeof record.scope === 'string' &&
typeof record.runtime === 'string' &&
record.version === 1
);
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
observed_timestamp TEXT,
level TEXT NOT NULL,
app TEXT NOT NULL,
service TEXT,
scope TEXT NOT NULL,
runtime TEXT NOT NULL,
trace_id TEXT,
span_id TEXT,
session_id TEXT,
user_id TEXT,
record_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_logs_level_app_service_scope ON logs(level, app, service, scope);
CREATE INDEX IF NOT EXISTS idx_logs_trace_id ON logs(trace_id);
CREATE INDEX IF NOT EXISTS idx_logs_session_id ON logs(session_id);
CREATE INDEX IF NOT EXISTS idx_logs_user_id ON logs(user_id);

View File

@@ -0,0 +1,28 @@
{
"name": "@logger/worker",
"version": "0.2.2",
"type": "module",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@logger/shared-schema": "workspace:*"
},
"devDependencies": {
"wrangler": "^4.82.2"
},
"scripts": {
"build": "tsc -b",
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"test": "vitest run --root ../.. packages/worker/src",
"typecheck": "tsc -p tsconfig.json --noEmit",
"types": "wrangler types src/worker-configuration.d.ts"
}
}

View File

@@ -0,0 +1,222 @@
import {createLogRecord, type LogRecord} from '@logger/shared-schema';
import {describe, expect, test} from 'vitest';
import {handleRequest, type Env} from './index.js';
const TOKEN = 'test-token';
function record(id: string, overrides: Partial<LogRecord> = {}): LogRecord {
return createLogRecord({
app: 'shop',
level: 'info',
message: `message ${id}`,
runtime: 'worker',
scope: 'orders',
timestamp: `2026-07-08T10:00:0${id}.000Z`,
...overrides,
}, {
id,
now: () => `2026-07-08T10:00:0${id}.000Z`,
observedNow: () => `2026-07-08T10:00:0${id}.001Z`,
});
}
function request(path: string, init: RequestInit = {}): Request {
return new Request(`https://logger.example.com${path}`, {
...init,
headers: {
authorization: `Bearer ${TOKEN}`,
...(init.body ? {'content-type': 'application/json'} : {}),
...init.headers,
},
});
}
describe('worker collector', () => {
test('healthz does not require auth', async () => {
const env = createEnv();
const response = await handleRequest(new Request('https://logger.example.com/healthz'), env);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ok: true});
});
test('accepts query requests without a bearer token', async () => {
const env = createEnv();
const response = await handleRequest(new Request('https://logger.example.com/query'), env);
expect(response.status).toBe(200);
expect(await response.json()).toEqual([]);
});
test('accepts ingest requests without a bearer token', async () => {
const env = createEnv();
const response = await handleRequest(new Request('https://logger.example.com/ingest', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify(record('1')),
}), env);
expect(response.status).toBe(204);
});
test('ingests and queries records in tail order', async () => {
const env = createEnv();
const records = [
record('1', {level: 'info'}),
record('2', {level: 'error', service: 'api', scope: 'orders.refund'}),
record('3', {level: 'error', service: 'worker', scope: 'orders.fulfill'}),
];
const ingest = await handleRequest(request('/ingest', {method: 'POST', body: JSON.stringify(records)}), env);
const query = await handleRequest(request('/query?level=error&app=shop&limit=2'), env);
expect(ingest.status).toBe(204);
expect(query.status).toBe(200);
expect(await query.json()).toMatchObject([
{id: '2', level: 'error', scope: 'orders.refund'},
{id: '3', level: 'error', scope: 'orders.fulfill'},
]);
});
test('query supports service scope and since filters', async () => {
const env = createEnv();
await handleRequest(request('/ingest', {method: 'POST', body: JSON.stringify([
record('1', {level: 'error', service: 'api', scope: 'orders.refund'}),
record('2', {level: 'error', service: 'worker', scope: 'orders.refund'}),
record('3', {level: 'error', service: 'api', scope: 'orders.refund'}),
])}), env);
const response = await handleRequest(request('/query?service=api&scope=orders.refund&since=2026-07-08T10:00:02.000Z'), env);
expect(await response.json()).toMatchObject([{id: '3'}]);
});
test('rejects invalid payloads before writing', async () => {
const env = createEnv();
const response = await handleRequest(request('/ingest', {
method: 'POST',
body: JSON.stringify([{id: 'bad'}]),
}), env);
const query = await handleRequest(request('/query'), env);
expect(response.status).toBe(400);
expect(await query.json()).toEqual([]);
});
test('caps query limit at 200', async () => {
const env = createEnv();
await handleRequest(request('/ingest', {method: 'POST', body: JSON.stringify(
Array.from({length: 205}, (_, index) => record(String(index).padStart(3, '0'), {
timestamp: `2026-07-08T10:${String(index).padStart(2, '0')}:00.000Z`,
})),
)}), env);
const response = await handleRequest(request('/query?limit=999'), env);
const body = await response.json() as LogRecord[];
expect(body).toHaveLength(200);
});
});
function createEnv(): Env {
return {
DB: new FakeD1Database() as unknown as D1Database,
};
}
class FakeD1Database {
readonly rows = new Map<string, Record<string, unknown>>();
prepare(sql: string): FakeStatement {
return new FakeStatement(this, sql);
}
async batch(statements: FakeStatement[]): Promise<unknown[]> {
return Promise.all(statements.map(statement => statement.run()));
}
}
class FakeStatement {
private values: unknown[] = [];
constructor(
private readonly db: FakeD1Database,
private readonly sql: string,
) {}
bind(...values: unknown[]): FakeStatement {
this.values = values;
return this;
}
async run(): Promise<unknown> {
if (!this.sql.includes('INSERT INTO logs')) {
throw new Error(`unsupported run SQL: ${this.sql}`);
}
const [
id,
timestamp,
observedTimestamp,
level,
app,
service,
scope,
runtime,
traceId,
spanId,
sessionId,
userId,
recordJson,
] = this.values;
this.db.rows.set(String(id), {
id,
timestamp,
observed_timestamp: observedTimestamp,
level,
app,
service,
scope,
runtime,
trace_id: traceId,
span_id: spanId,
session_id: sessionId,
user_id: userId,
record_json: recordJson,
});
return {success: true};
}
async all<T>(): Promise<{results: T[]}> {
let rows = [...this.db.rows.values()];
const conditions = this.sql.match(/WHERE ([\s\S]+?)ORDER BY/)?.[1]
.split(' AND ')
.map(condition => condition.trim())
.filter(Boolean) ?? [];
const limit = Number(this.values[this.values.length - 1]);
const filters = this.values.slice(0, -1);
for (let index = 0; index < conditions.length; index += 1) {
const condition = conditions[index];
const value = filters[index];
if (condition === 'timestamp >= ?') {
rows = rows.filter(row => String(row.timestamp) >= String(value));
} else if (condition.endsWith('= ?')) {
const column = condition.slice(0, -3).trim();
rows = rows.filter(row => row[column] === value);
} else {
throw new Error(`unsupported condition: ${condition}`);
}
}
rows.sort((left, right) => {
const byTimestamp = String(right.timestamp).localeCompare(String(left.timestamp));
return byTimestamp === 0 ? String(right.id).localeCompare(String(left.id)) : byTimestamp;
});
return {
results: rows.slice(0, limit).map(row => ({record_json: row.record_json}) as T),
};
}
}

View File

@@ -0,0 +1,247 @@
import {parseLogLine, type LogLevel, type LogRecord} from '@logger/shared-schema';
export interface Env {
DB: D1Database;
}
export interface QueryFilter {
app?: string;
level?: LogLevel;
service?: string;
scope?: string;
since?: string;
limit: number;
}
const MAX_BODY_BYTES = 1_048_576;
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 200;
const LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']);
export default {
fetch(request: Request, env: Env): Promise<Response> {
return handleRequest(request, env);
},
};
export async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
try {
if (request.method === 'GET' && url.pathname === '/healthz') {
return json({ok: true}, 200);
}
if (request.method === 'POST' && url.pathname === '/ingest') {
const records = await readRecords(request);
await insertRecords(env.DB, records);
return new Response(null, {status: 204});
}
if (request.method === 'GET' && url.pathname === '/query') {
const records = await queryRecords(env.DB, parseQuery(url.searchParams));
return json(records, 200);
}
return json({error: 'not_found'}, 404);
} catch (error) {
if (error instanceof HttpError) {
return json({error: error.code, message: error.message}, error.status);
}
console.error(JSON.stringify({
event: 'logger_worker_error',
message: error instanceof Error ? error.message : String(error),
}));
return json({error: 'internal_error'}, 500);
}
}
export async function insertRecords(db: D1Database, records: LogRecord[]): Promise<void> {
if (records.length === 0) {
throw new HttpError(400, 'invalid_payload', 'ingest payload must contain at least one record');
}
const statements = records.map(record => db.prepare(`
INSERT INTO logs (
id,
timestamp,
observed_timestamp,
level,
app,
service,
scope,
runtime,
trace_id,
span_id,
session_id,
user_id,
record_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
timestamp = excluded.timestamp,
observed_timestamp = excluded.observed_timestamp,
level = excluded.level,
app = excluded.app,
service = excluded.service,
scope = excluded.scope,
runtime = excluded.runtime,
trace_id = excluded.trace_id,
span_id = excluded.span_id,
session_id = excluded.session_id,
user_id = excluded.user_id,
record_json = excluded.record_json
`).bind(
record.id,
record.timestamp,
record.observedTimestamp ?? null,
record.level,
record.app,
record.service ?? null,
record.scope,
record.runtime,
record.traceId ?? null,
record.spanId ?? null,
record.sessionId ?? null,
record.userId ?? null,
JSON.stringify(record),
));
await db.batch(statements);
}
export async function queryRecords(db: D1Database, filter: QueryFilter): Promise<LogRecord[]> {
const where: string[] = [];
const bindings: D1PreparedStatement['bind'] extends (...args: infer Args) => unknown ? Args : unknown[] = [];
if (filter.level) {
where.push('level = ?');
bindings.push(filter.level);
}
if (filter.app) {
where.push('app = ?');
bindings.push(filter.app);
}
if (filter.service) {
where.push('service = ?');
bindings.push(filter.service);
}
if (filter.scope) {
where.push('scope = ?');
bindings.push(filter.scope);
}
if (filter.since) {
where.push('timestamp >= ?');
bindings.push(filter.since);
}
bindings.push(filter.limit);
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const result = await db.prepare(`
SELECT record_json
FROM logs
${whereSql}
ORDER BY timestamp DESC, id DESC
LIMIT ?
`).bind(...bindings).all<{record_json: string}>();
return result.results
.map(row => parseLogLine(row.record_json))
.reverse();
}
export function parseQuery(searchParams: URLSearchParams): QueryFilter {
const level = searchParams.get('level') ?? undefined;
if (level !== undefined && !LOG_LEVELS.has(level)) {
throw new HttpError(400, 'invalid_filter', 'level must be one of debug, info, warn, error');
}
return {
app: optionalParam(searchParams, 'app'),
level: level as LogLevel | undefined,
service: optionalParam(searchParams, 'service'),
scope: optionalParam(searchParams, 'scope'),
since: optionalParam(searchParams, 'since'),
limit: parseLimit(searchParams.get('limit')),
};
}
async function readRecords(request: Request): Promise<LogRecord[]> {
const raw = await readBoundedBody(request, MAX_BODY_BYTES);
let payload: unknown;
try {
payload = JSON.parse(raw);
} catch (error) {
throw new HttpError(400, 'invalid_json', error instanceof Error ? error.message : String(error));
}
const inputs = Array.isArray(payload) ? payload : [payload];
try {
return inputs.map(item => parseLogLine(JSON.stringify(item)));
} catch (error) {
throw new HttpError(400, 'invalid_record', error instanceof Error ? error.message : String(error));
}
}
async function readBoundedBody(request: Request, maxBytes: number): Promise<string> {
const length = request.headers.get('content-length');
if (length !== null && Number.parseInt(length, 10) > maxBytes) {
throw new HttpError(413, 'payload_too_large', `request body must be at most ${maxBytes} bytes`);
}
if (!request.body) return '';
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
while (true) {
const {done, value} = await reader.read();
if (done) break;
if (!value) continue;
received += value.byteLength;
if (received > maxBytes) {
throw new HttpError(413, 'payload_too_large', `request body must be at most ${maxBytes} bytes`);
}
chunks.push(value);
}
const buffer = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
buffer.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(buffer);
}
function parseLimit(raw: string | null): number {
if (raw === null) return DEFAULT_LIMIT;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 1) {
throw new HttpError(400, 'invalid_filter', 'limit must be a positive integer');
}
return Math.min(parsed, MAX_LIMIT);
}
function optionalParam(searchParams: URLSearchParams, name: string): string | undefined {
const value = searchParams.get(name);
return value === null || value.length === 0 ? undefined : value;
}
function json(value: unknown, status: number): Response {
return new Response(JSON.stringify(value), {
status,
headers: {'content-type': 'application/json; charset=utf-8'},
});
}
class HttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
this.name = 'HttpError';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2022", "WebWorker"],
"outDir": "dist",
"rootDir": "src",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"references": [
{"path": "../shared-schema"}
]
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "logger-collector",
"account_id": "67720b647ff2b55cf37ba3ef9e677083",
"main": "src/index.ts",
"compatibility_date": "2026-07-08",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"enabled": true,
"head_sampling_rate": 1
},
"d1_databases": [
{
"binding": "DB",
"database_name": "logger-collector",
"database_id": "25b488fd-a8c0-4ab1-8450-cfa02f80a643"
}
]
}

1640
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

8
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,8 @@
packages:
- packages/*
# pnpm 11 enables a ~17h minimumReleaseAge supply-chain gate by default, which
# blocks `pnpm test`/`pnpm build` whenever vitest (or any dep) was published
# inside the cutoff. This project trusts its committed lockfile, so disable the
# freshness gate here rather than in .npmrc.
minimumReleaseAge: 0

46
scripts/publish.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Publish all @logger/* packages to the private Verdaccio WITH a `readme` field
# injected into each manifest.
#
# Why this script (and not `pnpm publish -r`): Verdaccio v6 does NOT extract the
# README from the tarball — it only reads the manifest `readme` field, which
# modern `npm`/`pnpm publish` no longer populate. Without injection the registry
# UI shows "No README data found!" for every package. This script injects
# `readme` (from each package's README.md), rewrites `workspace:*` to the real
# version for the publish, then restores the source package.json.
#
# Usage:
# ./scripts/publish.sh # version = packages/shared-schema's version
# ./scripts/publish.sh 0.2.2 # explicit version
# REGISTRY=http://192.168.0.15:4873 ./scripts/publish.sh
set -euo pipefail
cd "$(dirname "$0")/.."
REGISTRY="${REGISTRY:-http://localhost:4873}"
VER="${1:-$(node -p "require('./packages/shared-schema/package.json').version")}"
PKGS=(shared-schema sdk collector cli)
echo "Publishing @logger/*@${VER} to ${REGISTRY} (readme injected)"
restore_all() { for f in packages/*/package.json.bak; do [ -f "$f" ] && mv "$f" "${f%.bak}"; done; }
trap restore_all EXIT
for pkg in "${PKGS[@]}"; do
f="packages/$pkg/package.json"
cp "$f" "$f.bak"
node -e "
const fs=require('fs');
const f='$f', pkgName='$pkg', ver='$VER';
const pkg=JSON.parse(fs.readFileSync(f,'utf8'));
pkg.readme = fs.readFileSync('packages/'+pkgName+'/README.md','utf8');
const deps = pkg.dependencies || {};
for (const d of Object.keys(deps)) if (deps[d] === 'workspace:*') deps[d] = '^'+ver;
fs.writeFileSync(f, JSON.stringify(pkg,null,2)+'\n');
"
echo "--- @logger/$pkg@$VER ---"
( cd "packages/$pkg" && npm publish --registry="$REGISTRY" --access public 2>&1 | tail -2 )
mv "$f.bak" "$f"
done
trap - EXIT
echo "done; source package.json files restored."
echo "verify: curl -s ${REGISTRY}/@logger/cli | node -e \"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log('readme len:',(JSON.parse(s).readme||'').length))\""

View File

@@ -0,0 +1,86 @@
---
name: logging-agent
description: Safely inspect project logs through the logging CLI and `.logging` configuration. Use when Codex or Claude Code needs to read recent logs, diagnose errors, summarize failures, check service health, or explain runtime behavior from logs without dumping raw sensitive telemetry.
---
# Logging Agent
## Overview
Use this skill to inspect logs as an agent-readable working view. Prefer small, filtered, actionable output over raw log dumps.
The project logging configuration lives under:
```ts
join(process.cwd(), ".logging")
```
Read `references/config-contract.md` when you need to create, review, or explain the `.logging/config.json` contract.
## Workflow
1. Discover the logging context.
- Check whether `.logging/config.json` exists in the current working directory.
- If it is missing, explain that the project has no logging profile yet and show the minimal config shape from `references/config-contract.md`.
- Do not invent service URLs, tokens, app names, or system names.
2. Choose the narrowest query.
- Start with the user's explicit filter: `level`, `app`, `service`, `scope`, `since`, or `limit`.
- If no filter is given, use the configured defaults.
- If no defaults exist, inspect only recent logs with a small limit.
3. Run the CLI.
- Prefer JSON output for machine inspection.
- Prefer compact human output when reporting back to the user.
- Use remote access only when the config declares a remote query endpoint and an auth strategy.
4. Summarize for action.
- Lead with failures, warnings, and repeated patterns.
- Include timestamps, level, app, service, scope, and message.
- Group repeated errors instead of pasting every line.
- Preserve enough evidence for the user to verify the claim.
5. Protect sensitive data.
- Never print bearer tokens, cookies, authorization headers, passwords, secrets, API keys, or raw production payloads.
- If a log line contains sensitive-looking content, summarize the diagnostic fact and mark the sensitive value as redacted.
- Do not mirror full stack traces unless the user explicitly asks and the trace is needed for debugging.
## Commands
Use the project CLI when available:
```bash
log tail --json --level=error --limit=20
log tail --json --app=<app> --service=<service> --scope=<scope> --limit=50
log tail --json --since=<iso-timestamp> --limit=100
log tail --remote --json # force the configured server instead of a local file
log config init --system=<name> # bootstrap .logging/config.json (+ .logging/.gitignore)
log config doctor # validate config, token, reachability, local file
```
When running from the source checkout after build:
```bash
node packages/cli/dist/index.js tail --json --limit=20
```
Source resolution is predictable and never silently hits a remote service: `--remote` queries the configured `server`; otherwise `--file` / `LOGGER_FILE` / `config.local.file` read locally; if only `server` is configured the CLI queries it; otherwise it falls back to `.logging/logs.jsonl` with a hint. Output is capped to `config.output.maxLines` and sensitive fields are redacted by default. If a capability you need is genuinely missing, report it as a product gap. Do not bypass the configured access model by calling private endpoints ad hoc or by reading raw log files directly.
## Report Format
Use this concise format:
```markdown
**Log Summary**
- Window: <time range or "latest N records">
- Filters: <level/app/service/scope/since>
- Result: <highest-signal finding>
**Findings**
- <timestamp> `<level>` `<app/service/scope>`: <message summary>
**Next Step**
- <one concrete debugging or verification action>
```
If no records are found, say which filters were used and suggest the next narrower verification step, such as checking whether the service is writing to the configured system name or whether the collector is reachable.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Logging Agent"
short_description: "Read project logs safely for AI agents"
default_prompt: "Use $logging-agent to inspect recent project logs and summarize actionable failures."

View File

@@ -0,0 +1,105 @@
# Logging Agent Config Contract
The logging agent profile lives at:
```ts
join(process.cwd(), ".logging", "config.json")
```
The `.logging` directory is project-local. It describes how an AI agent may read logs for the current project. It should not become a global machine credential store.
## Minimal Config
```json
{
"version": 1,
"systemName": "shop",
"local": {
"file": ".logging/logs.jsonl"
},
"defaults": {
"limit": 50
},
"output": {
"format": "compact",
"redact": true,
"maxLines": 200
}
}
```
## Remote Config
```json
{
"version": 1,
"systemName": "shop",
"server": {
"baseUrl": "https://logs.example.com",
"queryPath": "/query",
"ingestPath": "/ingest"
},
"auth": {
"type": "bearer",
"tokenEnv": "LOGGING_TOKEN"
},
"defaults": {
"app": "shop",
"service": "orders",
"limit": 50
},
"output": {
"format": "compact",
"redact": true,
"maxLines": 200
}
}
```
## Fields
| Field | Required | Meaning |
| --- | --- | --- |
| `version` | yes | Config schema version. Current value is `1`. |
| `systemName` | yes | Human and machine readable system identifier for this project. |
| `local.file` | no | Project-relative JSONL file for local reads. Default should be `.logging/logs.jsonl`. |
| `server.baseUrl` | no | Remote logging service origin. |
| `server.queryPath` | no | Remote query endpoint path. |
| `server.ingestPath` | no | Remote ingest endpoint path. |
| `auth.type` | no | `none`, `bearer`, or `header`. |
| `auth.tokenEnv` | conditional | Environment variable containing the token. Prefer this over storing secrets in JSON. |
| `auth.headerName` | conditional | Header name when `auth.type` is `header`. |
| `defaults` | no | Default query filters used when the user does not provide one. |
| `output.format` | no | `compact` or `json`. |
| `output.redact` | no | Whether the CLI should redact known sensitive fields. Default should be `true`. |
| `output.maxLines` | no | Maximum lines the agent should print before summarizing. |
## Security Rules
- Store secrets in environment variables, not in `.logging/config.json`.
- Treat `.logging/*.local.json`, `.logging/*.secret.json`, and `.logging/tokens*` as ignored local files.
- Keep `systemName`, endpoint shape, and non-secret defaults in config so agents can reason about the log source.
- Prefer compact summaries for model context and keep raw long logs as operator artifacts.
## Query Semantics
Agents and CLIs should support these filters consistently:
```text
level: debug | info | warn | error
app: string
service: string
scope: string
since: ISO-8601 timestamp
limit: positive integer
json: boolean
```
The default query should be safe:
```text
limit = min(config.defaults.limit ?? 20, config.output.maxLines ?? 200)
redact = config.output.redact ?? true
```
When a remote query endpoint is configured, the CLI should derive the final URL from `server.baseUrl + server.queryPath` and attach auth from the declared strategy only.

23
tsconfig.base.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@logger/shared-schema": ["packages/shared-schema/src/index.ts"],
"@logger/sdk": ["packages/sdk/src/index.ts"],
"@logger/collector": ["packages/collector/src/index.ts"],
"@logger/cli": ["packages/cli/src/index.ts"]
}
}
}

15
vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import {defineConfig} from 'vitest/config';
export default defineConfig({
test: {
include: ['packages/**/*.test.ts'],
},
resolve: {
alias: {
'@logger/shared-schema': new URL('./packages/shared-schema/src/index.ts', import.meta.url).pathname,
'@logger/sdk': new URL('./packages/sdk/src/index.ts', import.meta.url).pathname,
'@logger/collector': new URL('./packages/collector/src/index.ts', import.meta.url).pathname,
'@logger/cli': new URL('./packages/cli/src/index.ts', import.meta.url).pathname,
},
},
});