@prongsjs/logs 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/index.d.ts +21 -0
- package/dist/index.js +67 -0
- package/dist/tooling/index.d.ts +12 -0
- package/dist/tooling/index.js +68 -0
- package/dist/tooling/scan.d.ts +25 -0
- package/dist/tooling/scan.js +84 -0
- package/dist/tooling/spec.d.ts +9 -0
- package/dist/tooling/spec.js +57 -0
- package/package.json +64 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Drew Poling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @prongsjs/logs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Prongs logs profile: what happened, told to whoever runs the app. `log('audit.deleted', 'Page deleted', { pageId })` writes one structured JSON line with its level, and redacts values under secret-looking keys. Intents for audit, security, incident, job and debug, plus checks for `console` calls, catch blocks that only print, and secrets in logs.
|
|
4
|
+
|
|
5
|
+
Add it with `npx prongs add logs`. Early (0.x): expect changes. Full guide: https://github.com/drewpoling2/prongs#readme
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Intent, Profile } from '@prongsjs/core';
|
|
2
|
+
/**
|
|
3
|
+
* The logs profile: what happened, told to whoever runs the app. Each domain is a type of
|
|
4
|
+
* log (audit, security, incident, job, debug) and each intent a specific event within it,
|
|
5
|
+
* like `audit.deleted`. A ref-only `level.*` intent decides the level, whether it alerts, and
|
|
6
|
+
* whether it is kept in production, so changing `level.warn` changes every warning.
|
|
7
|
+
*/
|
|
8
|
+
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
|
9
|
+
export type LogsBinding = {
|
|
10
|
+
level?: LogLevel;
|
|
11
|
+
/** Someone should be told now, not just find it later. */
|
|
12
|
+
alert?: boolean;
|
|
13
|
+
/** False: dropped in production builds. */
|
|
14
|
+
production?: boolean;
|
|
15
|
+
};
|
|
16
|
+
/** The types of log, in the order they are usually read. */
|
|
17
|
+
export declare const LOG_DOMAINS: readonly ["audit", "security", "incident", "job", "debug"];
|
|
18
|
+
export type LogDomain = (typeof LOG_DOMAINS)[number];
|
|
19
|
+
export declare const logsProfile: Profile<LogsBinding>;
|
|
20
|
+
/** The starter intents `prongs add logs` puts in a registry: types of log every app has. */
|
|
21
|
+
export declare const LOG_INTENTS: Record<string, Intent<LogsBinding>>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** The types of log, in the order they are usually read. */
|
|
2
|
+
export const LOG_DOMAINS = ['audit', 'security', 'incident', 'job', 'debug'];
|
|
3
|
+
const domainOf = (id) => id.split('.')[0];
|
|
4
|
+
export const logsProfile = {
|
|
5
|
+
name: 'logs',
|
|
6
|
+
domains: [...LOG_DOMAINS, 'level'],
|
|
7
|
+
internalDomains: ['level'],
|
|
8
|
+
validate(id, intent, defs) {
|
|
9
|
+
const issues = [];
|
|
10
|
+
if (domainOf(id) === 'level') {
|
|
11
|
+
if (intent.kind !== 'primitive')
|
|
12
|
+
issues.push('level intents are primitive: they hold the level, alerting and production settings');
|
|
13
|
+
if (!intent.binding?.level)
|
|
14
|
+
issues.push('binding.level must be one of error, warn, info, debug');
|
|
15
|
+
return issues;
|
|
16
|
+
}
|
|
17
|
+
const levels = (intent.refs ?? []).filter((ref) => domainOf(ref) === 'level');
|
|
18
|
+
if (intent.kind !== 'semantic' || levels.length !== 1)
|
|
19
|
+
issues.push('a log intent refs exactly one level.* intent, which sets its level');
|
|
20
|
+
if (intent.binding?.level)
|
|
21
|
+
issues.push('set the level through a level.* ref, not on the intent');
|
|
22
|
+
void defs;
|
|
23
|
+
return issues;
|
|
24
|
+
},
|
|
25
|
+
merge: (base, next) => ({ ...base, ...next }),
|
|
26
|
+
};
|
|
27
|
+
const level = (description, binding) => ({ kind: 'primitive', description, binding });
|
|
28
|
+
const event = (ref, description, extra = {}) => ({
|
|
29
|
+
kind: 'semantic',
|
|
30
|
+
description,
|
|
31
|
+
refs: [`level.${ref}`],
|
|
32
|
+
...extra,
|
|
33
|
+
});
|
|
34
|
+
/** The starter intents `prongs add logs` puts in a registry: types of log every app has. */
|
|
35
|
+
export const LOG_INTENTS = {
|
|
36
|
+
'level.error': level('Error level: something is broken', { level: 'error' }),
|
|
37
|
+
'level.warn': level('Warning level: worth a look, not broken', { level: 'warn' }),
|
|
38
|
+
'level.info': level('Info level: a record of something that happened', { level: 'info' }),
|
|
39
|
+
'level.debug': level('Debug level: detail for development, dropped in production', { level: 'debug', production: false }),
|
|
40
|
+
'audit.created': event('info', 'Someone created something that matters later: an account, a page, an API key'),
|
|
41
|
+
'audit.changed': event('info', 'Someone changed something that matters: settings, a role, a published page'),
|
|
42
|
+
'audit.deleted': event('info', 'Someone deleted something: a page, an account, content'),
|
|
43
|
+
'audit.signedin': event('info', 'Someone signed in, the ordinary way', {
|
|
44
|
+
notFor: [{ context: 'a failed sign-in, or one from somewhere unusual', instead: 'security.signin' }],
|
|
45
|
+
}),
|
|
46
|
+
'audit.signedout': event('info', 'Someone signed out'),
|
|
47
|
+
'security.signin': event('warn', 'A sign-in failed, or succeeded from somewhere unusual', {
|
|
48
|
+
notFor: [{ context: 'an ordinary sign-in', instead: 'audit.signedin' }],
|
|
49
|
+
}),
|
|
50
|
+
'security.token': event('warn', 'A bad or expired token, secret or signature was presented'),
|
|
51
|
+
'security.limited': event('warn', 'A caller hit a rate limit'),
|
|
52
|
+
'incident.failed': event('error', 'Something broke and someone should look: a request, a job, a dependency', {
|
|
53
|
+
binding: { alert: true },
|
|
54
|
+
notFor: [
|
|
55
|
+
{ context: 'something that recovered or fell back', instead: 'incident.degraded' },
|
|
56
|
+
{ context: 'a caller’s own mistake, like invalid input: return a request.* error and do not log it as an incident' },
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
'incident.degraded': event('warn', 'It worked, but worse than it should: a fallback, a retry, a slow dependency'),
|
|
60
|
+
'job.started': event('info', 'A background job or script began: a sync, a backup, a migration'),
|
|
61
|
+
'job.finished': event('info', 'A job ended, with what it did: counts, duration', {
|
|
62
|
+
notFor: [{ context: 'a job that stopped on an error', instead: 'incident.failed' }],
|
|
63
|
+
}),
|
|
64
|
+
'debug.trace': event('debug', 'Detail for working on the code', {
|
|
65
|
+
notFor: [{ context: 'anything someone running the app needs to see', instead: 'job.finished' }],
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ProfileTooling } from '@prongsjs/core/engine';
|
|
2
|
+
export type LogsToolingOptions = {
|
|
3
|
+
cli?: string;
|
|
4
|
+
/** Where log() lives. Its own console calls are expected. */
|
|
5
|
+
helpersDir?: string;
|
|
6
|
+
/** Paths where console output is the point (scripts, tests, CLIs). */
|
|
7
|
+
consoleAllowed?: RegExp;
|
|
8
|
+
};
|
|
9
|
+
export declare const CONSOLE_ALLOWED: RegExp;
|
|
10
|
+
export declare function createLogsTooling(options?: LogsToolingOptions): ProfileTooling;
|
|
11
|
+
export { isConsoleCall, scanFile } from './scan.js';
|
|
12
|
+
export { LOGS_RULE_DOCS, logsSpecTooling } from './spec.js';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { locate } from '@prongsjs/core/engine';
|
|
2
|
+
import { scanFile } from './scan.js';
|
|
3
|
+
import { describeLogsBinding, logsSpecSection, logsUsage } from './spec.js';
|
|
4
|
+
const SOURCE = /\.[cm]?[jt]sx?$/;
|
|
5
|
+
export const CONSOLE_ALLOWED = /(^|\/)(scripts|bin|__tests__|tests?|e2e|cypress|playwright)\/|\.(test|spec|stories)\.[cm]?[jt]sx?$|(^|\/)[^/]*\.config\.[cm]?[jt]s$|(^|\/)global-(setup|teardown)\.[cm]?[jt]s$/;
|
|
6
|
+
/** The log intent a console method most often means. */
|
|
7
|
+
const CONSOLE_TO_LOG = {
|
|
8
|
+
error: 'incident.failed',
|
|
9
|
+
warn: 'incident.degraded',
|
|
10
|
+
info: 'job.finished',
|
|
11
|
+
log: 'debug.trace',
|
|
12
|
+
debug: 'debug.trace',
|
|
13
|
+
trace: 'debug.trace',
|
|
14
|
+
};
|
|
15
|
+
export function createLogsTooling(options = {}) {
|
|
16
|
+
const cli = options.cli ?? 'npx prongs';
|
|
17
|
+
const helpers = options.helpersDir?.replace(/\/+$/, '');
|
|
18
|
+
const consoleAllowed = options.consoleAllowed ?? CONSOLE_ALLOWED;
|
|
19
|
+
const scans = new WeakMap();
|
|
20
|
+
const scan = (ctx) => {
|
|
21
|
+
let result = scans.get(ctx.file);
|
|
22
|
+
if (!result)
|
|
23
|
+
scans.set(ctx.file, (result = scanFile(ctx.file.path, ctx.file.text)));
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
const isHelper = (path) => Boolean(helpers && (path === helpers || path.startsWith(`${helpers}/`)));
|
|
27
|
+
const at = (ctx, offset) => ({ path: ctx.file.path, ...locate(ctx.file.text, offset) });
|
|
28
|
+
const suggest = (ctx, id, example) => ctx.registry.has(id) ? { intent: id, text: `${example}: ${ctx.registry.defs[id].description}` } : { text: `${example} (add the starter intents with \`${cli} add logs\`)` };
|
|
29
|
+
/** A rule from a finder. Skips the helpers' own files, and files where console output is the point when `scriptsAllowed`. */
|
|
30
|
+
const rule = (id, severity, find, scriptsAllowed = false) => ({
|
|
31
|
+
id,
|
|
32
|
+
run(ctx) {
|
|
33
|
+
if (isHelper(ctx.file.path) || (scriptsAllowed && consoleAllowed.test(ctx.file.path)))
|
|
34
|
+
return [];
|
|
35
|
+
return find(ctx).map(({ offset, message, suggestions }) => ({ rule: id, severity, ...at(ctx, offset), message, suggestions }));
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const rules = [
|
|
39
|
+
rule('raw-console', 'warning', (ctx) => scan(ctx).consoles.map(({ offset, method }) => ({
|
|
40
|
+
offset,
|
|
41
|
+
message: `console.${method}() is not a log intent: nothing says who needs to know, and it may run in production. Record it with log().`,
|
|
42
|
+
suggestions: [suggest(ctx, CONSOLE_TO_LOG[method] ?? 'debug.trace', `log('${CONSOLE_TO_LOG[method] ?? 'debug.trace'}', …)`)],
|
|
43
|
+
})), true),
|
|
44
|
+
rule('swallowed-error', 'error', (ctx) => scan(ctx).swallowed.map(({ offset }) => ({
|
|
45
|
+
offset,
|
|
46
|
+
message: 'This catch only prints the error, so nothing notices it failed. Record it with log() and handle it, or return an error with fail().',
|
|
47
|
+
suggestions: [suggest(ctx, 'incident.failed', "log('incident.failed', '…', { cause: error })")],
|
|
48
|
+
})), true),
|
|
49
|
+
rule('secret-in-log', 'error', (ctx) => scan(ctx).secrets.map(({ offset, name }) => ({
|
|
50
|
+
offset,
|
|
51
|
+
message: `"${name}" looks like a secret and is being logged. Logs are kept and shared; leave it out, or log only that it was present.`,
|
|
52
|
+
suggestions: [],
|
|
53
|
+
}))),
|
|
54
|
+
];
|
|
55
|
+
return {
|
|
56
|
+
name: 'logs',
|
|
57
|
+
appliesTo: (path) => SOURCE.test(path) && !path.endsWith('.d.ts'),
|
|
58
|
+
findUsages(ctx) {
|
|
59
|
+
return scan(ctx).usages.map(({ offset, intent }) => ({ intent, via: 'log', ...at(ctx, offset) }));
|
|
60
|
+
},
|
|
61
|
+
rules,
|
|
62
|
+
describe: describeLogsBinding,
|
|
63
|
+
usage: logsUsage,
|
|
64
|
+
specSection: logsSpecSection,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export { isConsoleCall, scanFile } from './scan.js';
|
|
68
|
+
export { LOGS_RULE_DOCS, logsSpecTooling } from './spec.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
/** What the logs checks need from one file. Offsets point at the node to report. */
|
|
3
|
+
export type LogsScan = {
|
|
4
|
+
/** log('audit.deleted', ...) calls, by intent. */
|
|
5
|
+
usages: {
|
|
6
|
+
offset: number;
|
|
7
|
+
intent: string;
|
|
8
|
+
}[];
|
|
9
|
+
/** console.* calls, unless they are the whole body of a catch (those are in `swallowed`). */
|
|
10
|
+
consoles: {
|
|
11
|
+
offset: number;
|
|
12
|
+
method: string;
|
|
13
|
+
}[];
|
|
14
|
+
/** Catch handlers that only print the error. */
|
|
15
|
+
swallowed: {
|
|
16
|
+
offset: number;
|
|
17
|
+
}[];
|
|
18
|
+
/** Log or console calls that pass something named like a secret. */
|
|
19
|
+
secrets: {
|
|
20
|
+
offset: number;
|
|
21
|
+
name: string;
|
|
22
|
+
}[];
|
|
23
|
+
};
|
|
24
|
+
export declare function isConsoleCall(node: ts.Node): node is ts.CallExpression;
|
|
25
|
+
export declare function scanFile(path: string, text: string, logName?: string): LogsScan;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
const CONSOLE_METHODS = new Set(['log', 'info', 'warn', 'error', 'debug', 'trace']);
|
|
3
|
+
const SECRET_NAME = /(token|secret|password|passwd|authorization|cookie|apikey|api_key|accesskey|privatekey|credential)/i;
|
|
4
|
+
const MASKING = /(mask|redact|hash|obfuscate|truncate|censor)/i;
|
|
5
|
+
const INTENT_ID = /^[a-z]+(\.[a-z]+){1,2}$/;
|
|
6
|
+
export function isConsoleCall(node) {
|
|
7
|
+
return (ts.isCallExpression(node) &&
|
|
8
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
9
|
+
ts.isIdentifier(node.expression.expression) &&
|
|
10
|
+
node.expression.expression.text === 'console' &&
|
|
11
|
+
CONSOLE_METHODS.has(node.expression.name.text));
|
|
12
|
+
}
|
|
13
|
+
function secretIn(args) {
|
|
14
|
+
let found;
|
|
15
|
+
const visit = (node) => {
|
|
16
|
+
if (found)
|
|
17
|
+
return;
|
|
18
|
+
// mask(token), redact(secret): already made safe to print.
|
|
19
|
+
if (ts.isCallExpression(node) && MASKING.test(node.expression.getText()))
|
|
20
|
+
return;
|
|
21
|
+
if (ts.isIdentifier(node) && SECRET_NAME.test(node.text))
|
|
22
|
+
found = node.text;
|
|
23
|
+
else if (ts.isPropertyAccessExpression(node) && SECRET_NAME.test(node.name.text) && node.name.text !== 'length')
|
|
24
|
+
found = node.getText();
|
|
25
|
+
else if (ts.isPropertyAssignment(node) && SECRET_NAME.test(node.name.getText()))
|
|
26
|
+
found = node.name.getText();
|
|
27
|
+
if (!found && !ts.isFunctionLike(node))
|
|
28
|
+
ts.forEachChild(node, visit);
|
|
29
|
+
};
|
|
30
|
+
args.forEach(visit);
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
/** A catch whose body only prints: `catch (e) { console.error(e) }` or `.catch((e) => console.log(e))`. */
|
|
34
|
+
function onlyPrints(body) {
|
|
35
|
+
if (isConsoleCall(body))
|
|
36
|
+
return true;
|
|
37
|
+
if (ts.isBlock(body))
|
|
38
|
+
return body.statements.length > 0 && body.statements.every((s) => ts.isExpressionStatement(s) && isConsoleCall(s.expression));
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
export function scanFile(path, text, logName = 'log') {
|
|
42
|
+
const kind = path.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
43
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind);
|
|
44
|
+
const scan = { usages: [], consoles: [], swallowed: [], secrets: [] };
|
|
45
|
+
const inSwallowed = new Set();
|
|
46
|
+
const visit = (node) => {
|
|
47
|
+
if (ts.isCatchClause(node) && onlyPrints(node.block)) {
|
|
48
|
+
scan.swallowed.push({ offset: node.getStart(source) });
|
|
49
|
+
node.block.statements.forEach((s) => inSwallowed.add(s.expression));
|
|
50
|
+
}
|
|
51
|
+
if (ts.isCallExpression(node) &&
|
|
52
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
53
|
+
node.expression.name.text === 'catch' &&
|
|
54
|
+
node.arguments[0] &&
|
|
55
|
+
ts.isArrowFunction(node.arguments[0]) &&
|
|
56
|
+
onlyPrints(node.arguments[0].body)) {
|
|
57
|
+
const handler = node.arguments[0];
|
|
58
|
+
scan.swallowed.push({ offset: handler.getStart(source) });
|
|
59
|
+
if (ts.isBlock(handler.body))
|
|
60
|
+
handler.body.statements.forEach((s) => inSwallowed.add(s.expression));
|
|
61
|
+
else
|
|
62
|
+
inSwallowed.add(handler.body);
|
|
63
|
+
}
|
|
64
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === logName) {
|
|
65
|
+
const first = node.arguments[0];
|
|
66
|
+
if (first && (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first)) && INTENT_ID.test(first.text)) {
|
|
67
|
+
scan.usages.push({ offset: first.getStart(source), intent: first.text });
|
|
68
|
+
const secret = secretIn(node.arguments.slice(1));
|
|
69
|
+
if (secret)
|
|
70
|
+
scan.secrets.push({ offset: node.getStart(source), name: secret });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (isConsoleCall(node)) {
|
|
74
|
+
if (!inSwallowed.has(node))
|
|
75
|
+
scan.consoles.push({ offset: node.getStart(source), method: node.expression.name.text });
|
|
76
|
+
const secret = secretIn(node.arguments);
|
|
77
|
+
if (secret)
|
|
78
|
+
scan.secrets.push({ offset: node.getStart(source), name: secret });
|
|
79
|
+
}
|
|
80
|
+
ts.forEachChild(node, visit);
|
|
81
|
+
};
|
|
82
|
+
visit(source);
|
|
83
|
+
return scan;
|
|
84
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AnyRegistry } from '@prongsjs/core';
|
|
2
|
+
import type { RuleDoc, SpecContext, SpecTooling } from '@prongsjs/core/engine';
|
|
3
|
+
/** `warn`, `error, alerts`, `debug, not in production`: next to each intent in the spec's index. */
|
|
4
|
+
export declare function describeLogsBinding(binding: unknown): string;
|
|
5
|
+
export declare function logsSpecSection(registry: AnyRegistry, context: SpecContext): string;
|
|
6
|
+
/** What each logs rule finds, for the docs and for anyone reading a check report. */
|
|
7
|
+
export declare const LOGS_RULE_DOCS: readonly RuleDoc[];
|
|
8
|
+
export declare const logsUsage: (id: string) => string;
|
|
9
|
+
export declare const logsSpecTooling: SpecTooling;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { LOG_DOMAINS } from '../index.js';
|
|
2
|
+
/** `warn`, `error, alerts`, `debug, not in production`: next to each intent in the spec's index. */
|
|
3
|
+
export function describeLogsBinding(binding) {
|
|
4
|
+
const b = binding;
|
|
5
|
+
if (!b.level)
|
|
6
|
+
return '';
|
|
7
|
+
return '`' + b.level + '`' + (b.alert ? ', alerts' : '') + (b.production === false ? ', not in production' : '');
|
|
8
|
+
}
|
|
9
|
+
export function logsSpecSection(registry, context) {
|
|
10
|
+
if (!registry.ids.some((id) => LOG_DOMAINS.includes(id.split('.')[0])))
|
|
11
|
+
return '';
|
|
12
|
+
const from = context.componentsImport ?? './components/prongs';
|
|
13
|
+
return [
|
|
14
|
+
'## Logs rules',
|
|
15
|
+
'',
|
|
16
|
+
`\`import { log } from '${from}/log';\``,
|
|
17
|
+
'',
|
|
18
|
+
"- Record events with `log('<type>.<event>', message, data)`, never `console.*`. Pick the type by who needs to know: who did what (`audit.*`), security signals (`security.*`), something broke or degraded (`incident.*`), background work (`job.*`), development only (`debug.*`). Then the event within it.",
|
|
19
|
+
'- A `catch` that only prints the error hides it. Record it with `incident.*` and handle it, or return an error with `fail()`.',
|
|
20
|
+
'- Never put tokens, secrets, passwords or cookies in a log; `log()` redacts keys that look like secrets, but leave them out.',
|
|
21
|
+
'',
|
|
22
|
+
'```ts',
|
|
23
|
+
"log('audit.deleted', 'Page deleted', { pageId, by: user.id });",
|
|
24
|
+
"log('incident.degraded', 'Search fell back to the cached index', { tookMs });",
|
|
25
|
+
"log('job.finished', 'Redirects synced', { count: entries.length });",
|
|
26
|
+
'```',
|
|
27
|
+
].join('\n');
|
|
28
|
+
}
|
|
29
|
+
/** What each logs rule finds, for the docs and for anyone reading a check report. */
|
|
30
|
+
export const LOGS_RULE_DOCS = [
|
|
31
|
+
{
|
|
32
|
+
id: 'raw-console',
|
|
33
|
+
profile: 'logs',
|
|
34
|
+
severity: 'warning',
|
|
35
|
+
summary: '`console.*` in app code. Nothing says who needs to know, and it may run in production. Scripts, tests, config files and the helpers themselves are left alone.',
|
|
36
|
+
bad: "console.error('[sync]', error);",
|
|
37
|
+
good: "log('incident.failed', 'Sync stopped', { cause: error });",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'swallowed-error',
|
|
41
|
+
profile: 'logs',
|
|
42
|
+
severity: 'error',
|
|
43
|
+
summary: 'A `catch` whose only job is to print the error, so nothing notices the failure.',
|
|
44
|
+
bad: 'try { await sync(); } catch (e) { console.log(e); }',
|
|
45
|
+
good: "try { await sync(); } catch (e) { log('incident.failed', 'Sync stopped', { cause: e }); throw e; }",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'secret-in-log',
|
|
49
|
+
profile: 'logs',
|
|
50
|
+
severity: 'error',
|
|
51
|
+
summary: 'Something named like a token, secret, password or cookie passed to a log, unless it goes through a masking function first.',
|
|
52
|
+
bad: "log('security.token', 'Bad token', { token });",
|
|
53
|
+
good: "log('security.token', 'Bad token', { tokenPresent: Boolean(token) });",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
export const logsUsage = (id) => `log('${id}', 'What happened', { id });`;
|
|
57
|
+
export const logsSpecTooling = { name: 'logs', describe: describeLogsBinding, specSection: logsSpecSection, usage: logsUsage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prongsjs/logs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The Prongs logs profile: what happened, told to whoever runs the app. Audit, security, incident, job and debug intents, log(), and checks for console calls, swallowed errors and secrets in logs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"intents",
|
|
7
|
+
"logging",
|
|
8
|
+
"structured-logs",
|
|
9
|
+
"ai-agents"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/drewpoling2/prongs#readme",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/drewpoling2/prongs.git",
|
|
15
|
+
"directory": "packages/logs"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/drewpoling2/prongs/issues"
|
|
19
|
+
},
|
|
20
|
+
"author": "Drew Poling",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./tooling": {
|
|
31
|
+
"types": "./dist/tooling/index.d.ts",
|
|
32
|
+
"import": "./dist/tooling/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./spec": {
|
|
35
|
+
"types": "./dist/tooling/spec.d.ts",
|
|
36
|
+
"import": "./dist/tooling/spec.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"files": [
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"prepublishOnly": "npm run build"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@prongsjs/core": "0.2.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"typescript": ">=5.0.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"typescript": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"typescript": "^5.6.0",
|
|
61
|
+
"vitest": "^2.1.0"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
|
+
}
|