chore: import logging service
This commit is contained in:
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
16
.env.example
Normal 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
23
.gitignore
vendored
Normal 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
18
.logging/config.json
Normal 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
34
Dockerfile
Normal 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
249
README.md
Normal 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
58
docker-compose.yml
Normal 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:
|
||||||
173
docs/superpowers/plans/2026-07-06-standalone-logger.md
Normal file
173
docs/superpowers/plans/2026-07-06-standalone-logger.md
Normal 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`.
|
||||||
70
docs/superpowers/plans/2026-07-08-cloudflare-d1-collector.md
Normal file
70
docs/superpowers/plans/2026-07-08-cloudflare-d1-collector.md
Normal 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.
|
||||||
@@ -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
17
package.json
Normal 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
7
packages/cli/.npmignore
Normal 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
83
packages/cli/README.md
Normal 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
24
packages/cli/package.json
Normal 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
303
packages/cli/src/config.ts
Normal 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
165
packages/cli/src/doctor.ts
Normal 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};
|
||||||
|
}
|
||||||
481
packages/cli/src/index.test.ts
Normal file
481
packages/cli/src/index.test.ts
Normal 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
388
packages/cli/src/index.ts
Normal 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;
|
||||||
|
}
|
||||||
52
packages/cli/src/remote.ts
Normal file
52
packages/cli/src/remote.ts
Normal 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)));
|
||||||
|
}
|
||||||
14
packages/cli/tsconfig.json
Normal file
14
packages/cli/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
7
packages/collector/.npmignore
Normal file
7
packages/collector/.npmignore
Normal 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.*
|
||||||
70
packages/collector/README.md
Normal file
70
packages/collector/README.md
Normal 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.
|
||||||
23
packages/collector/package.json
Normal file
23
packages/collector/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
156
packages/collector/src/index.test.ts
Normal file
156
packages/collector/src/index.test.ts
Normal 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()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
129
packages/collector/src/index.ts
Normal file
129
packages/collector/src/index.ts
Normal 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;
|
||||||
|
}
|
||||||
32
packages/collector/src/server.ts
Normal file
32
packages/collector/src/server.ts
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
13
packages/collector/tsconfig.json
Normal file
13
packages/collector/tsconfig.json
Normal 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
7
packages/sdk/.npmignore
Normal 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
66
packages/sdk/README.md
Normal 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
20
packages/sdk/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
129
packages/sdk/src/index.test.ts
Normal file
129
packages/sdk/src/index.test.ts
Normal 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
196
packages/sdk/src/index.ts
Normal 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};
|
||||||
|
}
|
||||||
13
packages/sdk/tsconfig.json
Normal file
13
packages/sdk/tsconfig.json
Normal 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/shared-schema/.npmignore
Normal file
7
packages/shared-schema/.npmignore
Normal 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.*
|
||||||
79
packages/shared-schema/README.md
Normal file
79
packages/shared-schema/README.md
Normal 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.
|
||||||
17
packages/shared-schema/package.json
Normal file
17
packages/shared-schema/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
packages/shared-schema/src/index.test.ts
Normal file
77
packages/shared-schema/src/index.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
packages/shared-schema/src/index.ts
Normal file
170
packages/shared-schema/src/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/shared-schema/tsconfig.json
Normal file
10
packages/shared-schema/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
22
packages/worker/migrations/0001_create_logs.sql
Normal file
22
packages/worker/migrations/0001_create_logs.sql
Normal 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);
|
||||||
28
packages/worker/package.json
Normal file
28
packages/worker/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
222
packages/worker/src/index.test.ts
Normal file
222
packages/worker/src/index.test.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
247
packages/worker/src/index.ts
Normal file
247
packages/worker/src/index.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
12979
packages/worker/src/worker-configuration.d.ts
vendored
Normal file
12979
packages/worker/src/worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/worker/tsconfig.json
Normal file
14
packages/worker/tsconfig.json
Normal 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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
packages/worker/wrangler.jsonc
Normal file
19
packages/worker/wrangler.jsonc
Normal 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
1640
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
pnpm-workspace.yaml
Normal file
8
pnpm-workspace.yaml
Normal 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
46
scripts/publish.sh
Executable 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))\""
|
||||||
86
skills/logging-agent/SKILL.md
Normal file
86
skills/logging-agent/SKILL.md
Normal 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.
|
||||||
4
skills/logging-agent/agents/openai.yaml
Normal file
4
skills/logging-agent/agents/openai.yaml
Normal 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."
|
||||||
105
skills/logging-agent/references/config-contract.md
Normal file
105
skills/logging-agent/references/config-contract.md
Normal 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
23
tsconfig.base.json
Normal 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
15
vitest.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user