From 9aad2789f3a6c7ff68addbec6757139cfe151dc5 Mon Sep 17 00:00:00 2001 From: JuanGalilea Date: Fri, 24 Jul 2026 14:14:37 +0200 Subject: [PATCH] feat(qc-logging): add qc logging and basic type utils --- .github/scripts/before-beta-release.js | 2 +- .../workflows/_update_release_metadata.yaml | 2 +- .github/workflows/manual_publish_to_npm.yaml | 2 +- .github/workflows/manual_release_stable.yaml | 2 +- .github/workflows/on_master.yaml | 2 +- CHANGELOG.md | 3 +- package-lock.json | 48 ++---- package.json | 13 +- src/qc-logger/assert.ts | 27 ++++ src/qc-logger/checkpoint.ts | 45 ++++++ src/qc-logger/index.ts | 5 + src/qc-logger/is.ts | 139 ++++++++++++++++++ src/qc-logger/structured-logger.ts | 43 ++++++ src/type-utils/index.ts | 2 + src/type-utils/types.ts | 38 +++++ src/type-utils/unreachable.ts | 31 ++++ 16 files changed, 360 insertions(+), 44 deletions(-) create mode 100644 src/qc-logger/assert.ts create mode 100644 src/qc-logger/checkpoint.ts create mode 100644 src/qc-logger/index.ts create mode 100644 src/qc-logger/is.ts create mode 100644 src/qc-logger/structured-logger.ts create mode 100644 src/type-utils/index.ts create mode 100644 src/type-utils/types.ts create mode 100644 src/type-utils/unreachable.ts diff --git a/.github/scripts/before-beta-release.js b/.github/scripts/before-beta-release.js index 16d3d20..1550ab5 100644 --- a/.github/scripts/before-beta-release.js +++ b/.github/scripts/before-beta-release.js @@ -35,4 +35,4 @@ function addBetaSuffixToVersion(version) { .map((v) => Number(v.match(/\.(\d+)$/)[1])); const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers); return `${version}-beta.${lastPrereleaseNumber + 1}`; -} \ No newline at end of file +} diff --git a/.github/workflows/_update_release_metadata.yaml b/.github/workflows/_update_release_metadata.yaml index f92e09f..6a1595a 100644 --- a/.github/workflows/_update_release_metadata.yaml +++ b/.github/workflows/_update_release_metadata.yaml @@ -82,4 +82,4 @@ jobs: uses: apify/actions/signed-commit@v1.0.0 with: message: 'chore(release): Update changelog and package version [skip ci]' - github-token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} \ No newline at end of file + github-token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} diff --git a/.github/workflows/manual_publish_to_npm.yaml b/.github/workflows/manual_publish_to_npm.yaml index c22c50e..f26f0b9 100644 --- a/.github/workflows/manual_publish_to_npm.yaml +++ b/.github/workflows/manual_publish_to_npm.yaml @@ -50,4 +50,4 @@ jobs: - name: Publish run: npm publish --provenance --tag ${{ inputs.tag }} env: - NODE_AUTH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_NPM_TOKEN }} \ No newline at end of file + NODE_AUTH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_NPM_TOKEN }} diff --git a/.github/workflows/manual_release_stable.yaml b/.github/workflows/manual_release_stable.yaml index be7c591..e068348 100644 --- a/.github/workflows/manual_release_stable.yaml +++ b/.github/workflows/manual_release_stable.yaml @@ -78,4 +78,4 @@ jobs: { "ref": "${{ needs.release_metadata.outputs.changelog_commitish }}", "tag": "latest" - } \ No newline at end of file + } diff --git a/.github/workflows/on_master.yaml b/.github/workflows/on_master.yaml index 7e75b4a..56ab3ed 100644 --- a/.github/workflows/on_master.yaml +++ b/.github/workflows/on_master.yaml @@ -55,4 +55,4 @@ jobs: { "ref": "${{ needs.release_metadata.outputs.changelog_commitish }}", "tag": "beta" - } \ No newline at end of file + } diff --git a/CHANGELOG.md b/CHANGELOG.md index efdcc72..2f51f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,5 +4,4 @@ All notable changes to this project will be documented in this file. ## [0.1.2](https://github.com/apify/apify-actor-utils/releases/tag/v0.1.2) (2026-07-22) - -## [0.1.1](https://github.com/apify/apify-actor-utils/releases/tag/v0.1.1) (2026-07-22) \ No newline at end of file +## [0.1.1](https://github.com/apify/apify-actor-utils/releases/tag/v0.1.1) (2026-07-22) diff --git a/package-lock.json b/package-lock.json index 90121d6..e50d94f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "apify-actor-utils", + "name": "@apify/actor-utils", "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "apify-actor-utils", + "name": "@apify/actor-utils", "version": "0.1.2", "license": "ISC", "devDependencies": { @@ -25,13 +25,15 @@ }, "engines": { "node": ">=22.6.0" + }, + "peerDependencies": { + "@apify/log": "^2.5.44" } }, "node_modules/@apify/consts": { - "version": "2.53.3", - "resolved": "https://registry.npmjs.org/@apify/consts/-/consts-2.53.3.tgz", - "integrity": "sha512-X26SC8d6kLMSWzBcYldKgzKFJXGnTAka/bRmvL+iYHJEabFzVYupj64kGhar6QTPeGKNt6WR/hNE+dToNqHZQA==", - "dev": true, + "version": "2.54.2", + "resolved": "https://registry.npmjs.org/@apify/consts/-/consts-2.54.2.tgz", + "integrity": "sha512-ypdLZcGR0F+MwG+kOsvXar9JjZ32c7Vh086bywgNX31uVlIrnmDtOS0uWDG1VWPu4g2u0x1iA1tTtdMzRtyPpw==", "license": "Apache-2.0" }, "node_modules/@apify/eslint-config": { @@ -79,13 +81,12 @@ } }, "node_modules/@apify/log": { - "version": "2.5.41", - "resolved": "https://registry.npmjs.org/@apify/log/-/log-2.5.41.tgz", - "integrity": "sha512-c826B4jhfF6CBOgyGyZyiT4GrhPaFWrZegFbY6bk/ffQc5BCPqAOT315qEEpFE4d6hP0d86tdcsiRknxkEFO0A==", - "dev": true, + "version": "2.5.44", + "resolved": "https://registry.npmjs.org/@apify/log/-/log-2.5.44.tgz", + "integrity": "sha512-gDB4hkNfvDDkRRCMmYpY75twdCK2rFI88WBJoSTLpMpjHtFu3IU02gmYRj47aWOBIV/362Rc0c51ZuqBEBaSrw==", "license": "Apache-2.0", "dependencies": { - "@apify/consts": "^2.53.3", + "@apify/consts": "^2.54.2", "ansi-colors": "^4.1.1" } }, @@ -609,9 +610,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -626,9 +624,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -643,9 +638,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -660,9 +652,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -677,9 +666,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -694,9 +680,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -711,9 +694,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -728,9 +708,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1193,7 +1170,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index 8240098..fe4ad2b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,14 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./qc-logger": { + "types": "./dist/src/qc-logger/index.d.ts", + "default": "./dist/src/qc-logger/index.js" + }, + "./type-utils": { + "types": "./dist/src/type-utils/index.d.ts", + "default": "./dist/src/type-utils/index.js" } }, "files": [ @@ -59,5 +67,8 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.62.1" }, - "license": "ISC" + "license": "ISC", + "peerDependencies": { + "@apify/log": "^2.5.44" + } } diff --git a/src/qc-logger/assert.ts b/src/qc-logger/assert.ts new file mode 100644 index 0000000..d34499f --- /dev/null +++ b/src/qc-logger/assert.ts @@ -0,0 +1,27 @@ +import { type Ctx, emit, formatMessage } from './structured-logger.js'; + +/** + * Glorified assertion that also logs at `error` level. Throws a plain `Error` + * by default; pass e.g. Crawlee's `NonRetryableError` or `CriticalError` to + * control what the failure means to the crawler. + * + * + * ```ts + * const someEnvVar = process.env.SOME_ENV_VAR; // string | undefined + * assert(typeof someEnvVar === "string", "some-env-var"); + * // someEnvVar is now guaranteed to be a `string`, unless the assertion fails. + * assert(someEnvVar, "some-env-var"); + * // someEnvVar is now guaranteed to be a truthy `string` (non-empty), unless the assertion fails. + * ``` + */ +export function assert( + cond: unknown, + key: string, + ctx?: Ctx, + ErrorCtor: new (message: string) => Error = Error, +): asserts cond { + if (cond) return; + const error = new ErrorCtor(formatMessage('assert', key)); + emit('error', 'assert', key, { ...ctx, name: error.name, stack: error.stack }); + throw error; +} diff --git a/src/qc-logger/checkpoint.ts b/src/qc-logger/checkpoint.ts new file mode 100644 index 0000000..f73aeb9 --- /dev/null +++ b/src/qc-logger/checkpoint.ts @@ -0,0 +1,45 @@ +import { type Ctx, emit } from './structured-logger.js'; + +/** + * Logs an `info`-level checkpoint marking that a point in the run was reached. + * Unlike the `is*` predicates or {@link assert}, this has no condition — it + * always emits — so it's meant for tracing progress through a run. + * + * @param key - Identifies the checkpoint; emitted as `qc:checkpoint:`. + * @param ctx - Optional extra fields to attach to the log line. + * + * @example + * ```ts + * checkpoint("started"); + * // logs `qc:checkpoint:started` + * + * await doWork(); + * checkpoint("work-done", { itemsProcessed: 42 }); + * // logs `qc:checkpoint:work-done` with { itemsProcessed: 42 } + * ``` + */ +export function checkpoint(key: string, ctx?: Ctx): void { + emit('info', 'checkpoint', key, ctx); +} + +/** + * Conditionally logs an `info`-level checkpoint — only when `cond` is truthy. + * A convenience wrapper over {@link checkpoint} for guarding a marker behind a + * condition without an explicit `if` at the call site. + * + * @param cond - The condition; the checkpoint is emitted only when it's truthy. + * @param key - Identifies the checkpoint; emitted as `qc:checkpoint:`. + * @param ctx - Optional extra fields to attach to the log line. + * + * @example + * ```ts + * checkpointIf(items.length === 0, "empty-batch"); + * // logs `qc:checkpoint:empty-batch` only when the batch is empty + * + * checkpointIf(retries > 3, "many-retries", { retries }); + * // logs `qc:checkpoint:many-retries` with { retries } only past the threshold + * ``` + */ +export function checkpointIf(cond: unknown, key: string, ctx?: Ctx): void { + if (cond) checkpoint(key, ctx); +} diff --git a/src/qc-logger/index.ts b/src/qc-logger/index.ts new file mode 100644 index 0000000..f731b30 --- /dev/null +++ b/src/qc-logger/index.ts @@ -0,0 +1,5 @@ +export { checkpoint, checkpointIf } from './checkpoint.js'; +export { assert } from './assert.js'; +export { isFalsy, isTruthy, isNullish, isDefined } from './is.js'; +export { emit, formatMessage } from './structured-logger.js'; +export type { Ctx } from './structured-logger.js'; diff --git a/src/qc-logger/is.ts b/src/qc-logger/is.ts new file mode 100644 index 0000000..2f575a1 --- /dev/null +++ b/src/qc-logger/is.ts @@ -0,0 +1,139 @@ +import type { Defined, Falsy, Nullish, Truthy } from '../type-utils/types.js'; +import { type Ctx, emit } from './structured-logger.js'; + +/** + * Checks for falsy values and logs a `warning` when they are. + * + * Sample usage: + * ```ts + * if (isFalsy(someValue, "my-key-here")) { + * // someValue is falsy, warning gets logged `qc:is:falsy:my-key-here` + * return; // do my early return + * } + * ``` + * + * The overloads narrow the return type based on what's statically known about + * `value`: a {@link Truthy} input returns the literal `false`, a {@link Falsy} + * input returns the literal `true`, and only genuinely uncertain values get the + * `value is Falsy & T` type guard. This means a redundant call — e.g. + * `isFalsy` on something the compiler already knows is truthy — produces a + * branch whose condition is a constant. + * + * Enable the `@typescript-eslint/no-unnecessary-condition` rule to have that + * surfaced as a lint error instead of a silent dead branch. The rule ships with + * both [typescript-eslint](https://typescript-eslint.io/rules/no-unnecessary-condition/) + * and [oxlint](https://oxc.rs/docs/guide/usage/linter/rules/typescript/no-unnecessary-condition.html) + * (note: it requires type-aware linting). + */ +export function isFalsy(value: Truthy, key: string, ctx?: Ctx): false; +export function isFalsy(value: Falsy, key: string, ctx?: Ctx): true; +export function isFalsy(value: T, key: string, ctx?: Ctx): value is Falsy & T; +export function isFalsy(value: T, key: string, ctx?: Ctx): value is Falsy & T { + const result = !value; + if (result) emit('warning', 'is:falsy', key, ctx); + return result; +} + +/** + * Checks for truthy values and logs a `warning` when they are — the + * inverse of {@link isFalsy}. + * + * Sample usage: + * ```ts + * if (isTruthy(someValue, "my-key-here")) { + * // someValue is truthy, warning gets logged `qc:is:truthy:my-key-here` + * return someValue; // do my early return + * } + * // fallback behavior here + * ``` + * + * Mirroring {@link isFalsy}, the overloads narrow the return type based on what's + * statically known about `value`: a {@link Falsy} input returns the literal + * `false`, a {@link Truthy} input returns the literal `true`, and only genuinely + * uncertain values get the `value is Truthy` type guard. A redundant call — + * e.g. `isTruthy` on something the compiler already knows is falsy — produces a + * branch whose condition is a constant. + * + * Enable the `@typescript-eslint/no-unnecessary-condition` rule to have that + * surfaced as a lint error instead of a silent dead branch. The rule ships with + * both [typescript-eslint](https://typescript-eslint.io/rules/no-unnecessary-condition/) + * and [oxlint](https://oxc.rs/docs/guide/usage/linter/rules/typescript/no-unnecessary-condition.html) + * (note: it requires type-aware linting). + */ +export function isTruthy(value: Falsy, key: string, ctx?: Ctx): false; +export function isTruthy(value: Truthy, key: string, ctx?: Ctx): true; +export function isTruthy(value: T, key: string, ctx?: Ctx): value is Truthy; +export function isTruthy(value: T, key: string, ctx?: Ctx): value is Truthy { + const result = Boolean(value); + if (result) emit('warning', 'is:truthy', key, ctx); + return result; +} + +/** + * Checks for `null | undefined` values and logs a `warning` when they are. + * Unlike {@link isFalsy}, `0`, `false`, and `""` are not considered a match. + * + * Sample usage: + * ```ts + * if (isNullish(someValue, "my-key-here")) { + * // someValue is null or undefined, warning gets logged `qc:is:nullish:my-key-here` + * return; // do my early return + * } + * // keep on truckin' + * ``` + * + * Like {@link isFalsy}, the overloads narrow the return type based on what's + * statically known about `value`: a {@link Defined} input returns the literal + * `false`, a {@link Nullish} input returns the literal `true`, and only + * genuinely uncertain values get the `value is Nullish & T` type guard. A + * redundant call — e.g. `isNullish` on something the compiler already knows is + * defined — produces a branch whose condition is a constant. + * + * Enable the `@typescript-eslint/no-unnecessary-condition` rule to have that + * surfaced as a lint error instead of a silent dead branch. The rule ships with + * both [typescript-eslint](https://typescript-eslint.io/rules/no-unnecessary-condition/) + * and [oxlint](https://oxc.rs/docs/guide/usage/linter/rules/typescript/no-unnecessary-condition.html) + * (note: it requires type-aware linting). + */ +export function isNullish(value: Defined, key: string, ctx?: Ctx): false; +export function isNullish(value: Nullish, key: string, ctx?: Ctx): true; +export function isNullish(value: T, key: string, ctx?: Ctx): value is Nullish & T; +export function isNullish(value: T, key: string, ctx?: Ctx): value is Nullish & T { + const result = value === undefined || value === null; + if (result) emit('warning', 'is:nullish', key, ctx); + return result; +} + +/** + * Checks that a value is neither `null` nor `undefined` and logs a + * `warning` when that's the case — the inverse of {@link isNullish}. + * + * Sample usage: + * ```ts + * if (isDefined(someValue, "my-key-here")) { + * // someValue is neither null nor undefined, warning gets logged `qc:is:defined:my-key-here` + * return; // do my early return + * } + * ``` + * + * Like {@link isFalsy}, the overloads narrow the return type based on what's + * statically known about `value`: a {@link Nullish} input returns the literal + * `false`, a {@link Defined} input returns the literal `true`, and only + * genuinely uncertain values get the `value is Defined` type guard. A + * redundant call — e.g. `isDefined` on something the compiler already knows is + * defined — produces a branch whose condition is a constant. + * + * Enable the `@typescript-eslint/no-unnecessary-condition` rule to have that + * surfaced as a lint error instead of a silent dead branch. The rule ships with + * both [typescript-eslint](https://typescript-eslint.io/rules/no-unnecessary-condition/) + * and [oxlint](https://oxc.rs/docs/guide/usage/linter/rules/typescript/no-unnecessary-condition.html) + * (note: it requires type-aware linting). + */ +export function isDefined(value: Nullish, key: string, ctx?: Ctx): false; +export function isDefined(value: Defined, key: string, ctx?: Ctx): true; +export function isDefined(value: T, key: string, ctx?: Ctx): value is Defined; +export function isDefined(value: T, key: string, ctx?: Ctx): value is Defined { + const result = value !== undefined && value !== null; + if (result) emit('warning', 'is:defined', key, ctx); + return result; +} diff --git a/src/qc-logger/structured-logger.ts b/src/qc-logger/structured-logger.ts new file mode 100644 index 0000000..319cef4 --- /dev/null +++ b/src/qc-logger/structured-logger.ts @@ -0,0 +1,43 @@ +import log from '@apify/log'; + +export type Ctx = Record; + +/** + * Keys are part of the log line's parse structure (`qc::`) + * Turns any string into a valid qc key. + * + * If a key is completely invalid, it is replaced with "invalid-key". + */ +function normalizeKey(key: string): string { + return ( + String(key) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-|-$/g, '') || 'invalid-key' + ); +} + +/** + * Builds the canonical log line (`qc::`) without emitting it. + * Exposed so callers (e.g. `assert`) can reuse the exact same message when + * constructing an error to throw. + */ +export function formatMessage(qcVerb: string, rawKey: string): string { + return `qc:${qcVerb}:${normalizeKey(rawKey)}`; +} + +export function emit(level: 'debug' | 'info' | 'warning' | 'error', qcVerb: string, rawKey: string, ctx?: Ctx): string { + let message: string; + try { + const qcKey = normalizeKey(rawKey); + message = formatMessage(qcVerb, rawKey); + // Caller ctx is spread first so it can never clobber the canonical fields. + // Run/actor identity is not attached here — Mezmo enriches lines with it. + log[level](message, { ...ctx, level, qcKey, qcVerb }); + return message; + } catch { + // Telemetry must never break a run. + message ??= `qc:unknown:qc-error-generating-key`; + return message; + } +} diff --git a/src/type-utils/index.ts b/src/type-utils/index.ts new file mode 100644 index 0000000..c6b1e34 --- /dev/null +++ b/src/type-utils/index.ts @@ -0,0 +1,2 @@ +export { unreachable } from './unreachable.js'; +export type { Falsy, Nullish, Truthy, Defined } from './types.js'; diff --git a/src/type-utils/types.ts b/src/type-utils/types.ts new file mode 100644 index 0000000..da34d82 --- /dev/null +++ b/src/type-utils/types.ts @@ -0,0 +1,38 @@ +export type Falsy = false | 0 | '' | null | undefined; +export type Nullish = null | undefined; + +/** + * Narrows `T` to only its truthy members by stripping every {@link Falsy} + * value (`false`, `0`, `''`, `null`, `undefined`). + * + * Unlike `Exclude`, this checks each falsy literal individually so a + * broad type such as `boolean` or `number` is preserved rather than collapsed: + * `boolean` only loses `false` if `false` is the whole type, not when it's part + * of `boolean`. Object types short-circuit first, so `''`/`0` don't accidentally + * match structural (`{}`) types. + * + * @example + * ```ts + * type A = Truthy; // string + * type B = Truthy<0 | 1 | 2>; // 1 | 2 + * type C = Truthy<'' | 'hello'>; // 'hello' + * type D = Truthy; // never + * ``` + */ +export type Truthy = + // clause to catch "" extending {} + T extends object + ? T + : false extends T + ? never + : 0 extends T + ? never + : '' extends T + ? never + : null extends T + ? never + : undefined extends T + ? never + : T; + +export type Defined = Exclude; diff --git a/src/type-utils/unreachable.ts b/src/type-utils/unreachable.ts new file mode 100644 index 0000000..81577fe --- /dev/null +++ b/src/type-utils/unreachable.ts @@ -0,0 +1,31 @@ +/** + * Asserts that a code path is unreachable, enabling exhaustiveness checks. + * + * Pass a value of type `never` to this function to let the TypeScript compiler + * verify that all cases of a union have been handled. If a new variant is added + * to the union but not handled, the call becomes a compile-time error. At + * runtime, reaching this function throws an `Error`. + * + * @param x - The value that should be of type `never` at this point. + * @throws {Error} Always throws, since this code should never be reached. + * + * @example + * ```ts + * type Shape = 'circle' | 'square'; + * + * function area(shape: Shape): number { + * switch (shape) { + * case 'circle': + * return Math.PI; + * case 'square': + * return 1; + * default: + * // Compile-time error here if a new Shape variant is unhandled. + * return unreachable(shape); + * } + * } + * ``` + */ +export function unreachable(x: never): never { + throw new Error(`unreachable: ${x}`); +}