diff --git a/docs/api-reference/veryfront/observability.md b/docs/api-reference/veryfront/observability.md index b46bec055f..3bf86d2395 100644 --- a/docs/api-reference/veryfront/observability.md +++ b/docs/api-reference/veryfront/observability.md @@ -43,13 +43,13 @@ const result = await withSpan("load-data", async () => { | Name | Description | Source | | ---------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `addSpanEvent` | Event emitted for add span. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L70) | -| `captureApplicationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L223) | +| `captureApplicationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L224) | | `createChildSpan` | Create child span. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L79) | | `createFileLogSubscriber` | Create file log subscriber. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/file-log-subscriber.ts#L541) | | `createOpenTelemetryServiceTracer` | Create open telemetry service tracer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/service-tracer.ts#L364) | | `endSpan` | End an active tracing span. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L57) | | `extractContext` | Context for extract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L88) | -| `flushApplicationErrors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L241) | +| `flushApplicationErrors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L242) | | `getActiveContext` | Context for get active. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L98) | | `getErrorCollector` | Return error collector. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/error-collector.ts#L406) | | `getGlobalMetricsAPI` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/api-shim.ts#L667) | @@ -58,7 +58,7 @@ const result = await withSpan("load-data", async () => { | `getMetricsState` | State for get metrics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/metrics/index.ts#L38) | | `getTraceContext` | Context for get trace. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/otlp-setup.ts#L500) | | `initAutoInstrumentation` | Initialize automatic instrumentation wrappers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/auto-instrument/orchestrator.ts#L15) | -| `initializeApplicationErrorReporter` | Activate an explicitly selected reporter initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L148) | +| `initializeApplicationErrorReporter` | Activate an explicitly selected reporter initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L149) | | `initializeOTLP` | Initialize OTLP tracing export. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/otlp-setup.ts#L113) | | `initMetrics` | Initialize metrics collection. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/metrics/index.ts#L20) | | `initTracing` | Initialize tracing for the current runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L18) | @@ -134,7 +134,7 @@ const result = await withSpan("load-data", async () => { | `ApplicationErrorReporter` | Provider-neutral application error capture and flush interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L23) | | `ApplicationErrorReporterInitializationContext` | Runtime context passed to an explicitly selected reporter initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L9) | | `ApplicationErrorReporterInitializer` | Application-composition contract for an error-reporting implementation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L20) | -| `ApplicationErrorReporterLifecycle` | Active application-error reporter ownership. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L27) | +| `ApplicationErrorReporterLifecycle` | Active application-error reporter ownership. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L28) | | `ApplicationErrorReporterSession` | Reporter and cleanup ownership returned by an application-selected initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L14) | | `AttributeValue` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/api-shim.ts#L33) | | `AutoInstrumentConfig` | Configuration used by auto instrument. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/auto-instrument/types.ts#L24) | @@ -237,8 +237,8 @@ import { | Name | Description | Source | | ---------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `captureApplicationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L223) | -| `flushApplicationErrors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L241) | +| `captureApplicationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L224) | +| `flushApplicationErrors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L242) | | `initializeSentry` | Initialize the process-wide Sentry reporter once. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/sentry.ts#L86) | | `initializeSentryFromEnv` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/sentry.ts#L70) | | `isSentryEnabled` | Return whether Sentry is explicitly enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/sentry.ts#L39) | diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index 1868c82999..fbd8476940 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -2209,6 +2209,185 @@ export default config as const; assertEquals(reads, 4); }); + describe("hosted config negative caching", () => { + const productionSourceContext = { + productionMode: true, + releaseId: "release-negative-cache", + environmentName: "Production", + } as const; + type PreparedContext = Awaited< + ReturnType + >; + type TestAdapter = ReturnType; + + function createHostedAdapter( + readSource: () => string = () => 'export default { title: "source" };', + ): TestAdapter { + const adapter = setup(); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + return readSource(); + }, + }); + return adapter; + } + + function loadProductionHostedConfig( + adapter: TestAdapter, + preparedContext: PreparedContext, + ) { + const projectId = "project-negative-cache"; + return runWithRequestContext( + { + projectSlug: projectId, + projectId, + token: "token", + productionMode: true, + releaseId: productionSourceContext.releaseId, + environmentName: productionSourceContext.environmentName, + }, + () => + getHostedConfig(`/hosted/${projectId}`, adapter, { + cacheKey: projectId, + sourceContext: productionSourceContext, + preparedContext, + }), + ); + } + + function prepareProductionContext(): Promise { + return prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant" }, + }); + } + + it("does not re-evaluate a deterministically rejected hosted config on later requests", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + throw new DeclarativeConfigEvaluationError({ + code: "forbidden-capability", + phase: "validate", + reason: "unsupported-call", + }); + }); + + const first = await assertRejects( + () => loadProductionHostedConfig(adapter, preparedContext), + VeryfrontError, + ) as VeryfrontError; + const second = await assertRejects( + () => loadProductionHostedConfig(adapter, preparedContext), + VeryfrontError, + ) as VeryfrontError; + + assertEquals(first.slug, "config-parse-error"); + assertEquals(second.slug, "config-parse-error"); + assertStringIncludes( + first.detail ?? "", + "Hosted configuration rejected (forbidden-capability: unsupported-call)", + ); + assertStringIncludes( + second.detail ?? "", + "Hosted configuration rejected (forbidden-capability: unsupported-call)", + ); + assertEquals( + evaluations, + 1, + "a deterministic rejection must be negatively cached, not re-evaluated per request", + ); + }); + + it("re-evaluates a rejected hosted config after the source changes", async () => { + let source = "const forbidden = process.env;\nexport default { title: 'source' };"; + const adapter = createHostedAdapter(() => source); + const preparedContext = await prepareProductionContext(); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + if (evaluations === 1) { + throw new DeclarativeConfigEvaluationError({ + code: "forbidden-capability", + phase: "validate", + reason: "unsupported-call", + }); + } + return { title: "corrected" }; + }); + + await assertRejects( + () => loadProductionHostedConfig(adapter, preparedContext), + VeryfrontError, + ); + source = 'export default { title: "corrected" };'; + const corrected = await loadProductionHostedConfig(adapter, preparedContext); + + assertEquals(corrected.title, "corrected"); + assertEquals(evaluations, 2); + }); + + it("re-evaluates a rejected hosted config after clearConfigCache", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + throw new DeclarativeConfigEvaluationError({ + code: "forbidden-capability", + phase: "validate", + reason: "unsupported-call", + }); + }); + + await assertRejects( + () => loadProductionHostedConfig(adapter, preparedContext), + VeryfrontError, + ); + clearConfigCache(); + await assertRejects( + () => loadProductionHostedConfig(adapter, preparedContext), + VeryfrontError, + ); + + assertEquals(evaluations, 2); + }); + + it("never negatively caches retryable infrastructure failures", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + if (evaluations === 1) { + throw new DeclarativeConfigEvaluationError({ + code: "evaluator-unavailable", + phase: "worker", + reason: "worker-timeout", + retryable: true, + }); + } + return { title: "recovered" }; + }); + + await assertRejects( + () => loadProductionHostedConfig(adapter, preparedContext), + VeryfrontError, + ); + const recovered = await loadProductionHostedConfig(adapter, preparedContext); + + assertEquals(recovered.title, "recovered"); + assertEquals(evaluations, 2); + }); + }); + describe("hosted config single-flight", () => { const productionSourceContext = { productionMode: true, diff --git a/src/config/loader.ts b/src/config/loader.ts index 0b57ed1aec..3f68218958 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -567,6 +567,26 @@ const configCacheByProject = new LRUCache({ maxEntries: DEFAULT_CONFIG_CACHE_MAX_ENTRIES, }); +interface HostedConfigFailureCacheEntry { + readonly revision: number; + readonly error: DeclarativeConfigEvaluationError; +} + +/** + * Negative cache for deterministic hosted config rejections. + * + * The hosted cache key already folds in the exact source digest, policy + * version and environment fingerprint, so a rejected source stays rejected + * until the tenant ships different content; re-sending it to the evaluator + * worker on every request only repeats the same failure. + */ +const hostedConfigFailureCacheByProject = new LRUCache< + string, + HostedConfigFailureCacheEntry +>({ + maxEntries: DEFAULT_CONFIG_CACHE_MAX_ENTRIES, +}); + type HostedConfigEvaluator = typeof evaluatePreparedDeclarativeConfigInWorker; interface HostedConfigSourceSelection { @@ -632,8 +652,9 @@ const trustedConfigFlights = new IntrinsicMap(); const trustedVirtualFilesystemIds = new IntrinsicWeakMap(); let nextTrustedVirtualFilesystemId = 1; -// Register cache for monitoring +// Register caches for monitoring registerLRUCache("config-cache", configCacheByProject); +registerLRUCache("config-failure-cache", hostedConfigFailureCacheByProject); let cacheRevision = 0; @@ -1069,6 +1090,19 @@ function buildHostedConfigFlightKey(hostedCacheKey: string, revision: number): s return `${revision}:${hostedCacheKey}`; } +/** + * Whether a hosted evaluation failure is guaranteed to repeat for the same + * cache key. Worker-phase and retryable failures are infrastructure + * conditions that can succeed on retry, so they must never be cached. + */ +function isDeterministicHostedConfigRejection( + error: unknown, +): error is DeclarativeConfigEvaluationError { + return error instanceof DeclarativeConfigEvaluationError && + !error.retryable && + error.phase !== "worker"; +} + function createHostedConfigFlight( flightKey: string, hostedCacheKey: string, @@ -1117,6 +1151,15 @@ function createHostedConfigFlight( }, (error: unknown) => { finish(); + if ( + usePersistentCache && cacheRevision === revisionAtStart && + isDeterministicHostedConfigRejection(error) + ) { + hostedConfigFailureCacheByProject.set(hostedCacheKey, { + revision: revisionAtStart, + error, + }); + } result.reject(error); }, ); @@ -1739,6 +1782,13 @@ function loadHostedConfigFromSource( return cached.config; } + const cachedFailure = usePersistentCache + ? hostedConfigFailureCacheByProject.get(hostedCacheKey) + : undefined; + if (cachedFailure?.revision === revisionAtStart) { + throw cachedFailure.error; + } + const flight = getOrCreateHostedConfigFlight( hostedCacheKey, payload, @@ -2492,6 +2542,7 @@ export function __getTrustedConfigFlightStateForTests(): Readonly<{ export function clearConfigCache(): void { configCacheByProject.clear(); + hostedConfigFailureCacheByProject.clear(); cacheRevision++; } diff --git a/src/observability/application-errors.test.ts b/src/observability/application-errors.test.ts index e285dfb57d..ba7e18cf21 100644 --- a/src/observability/application-errors.test.ts +++ b/src/observability/application-errors.test.ts @@ -13,6 +13,7 @@ import { setApplicationErrorReporter, } from "./application-errors.ts"; import type { ApplicationErrorContext as SharedApplicationErrorContext } from "./application-error-contract.ts"; +import { CONFIG_PARSE_ERROR, INITIALIZATION_ERROR } from "#veryfront/errors"; it("application error reporter is optional", async () => { setApplicationErrorReporter(undefined); @@ -96,6 +97,40 @@ it("application error reporter ignores expected cancellation", () => { assertEquals(captured, false); }); +it("application error reporter ignores client-class veryfront errors", () => { + const captures: unknown[] = []; + setApplicationErrorReporter({ + capture(error) { + captures.push(error); + return "event-id"; + }, + flush: () => Promise.resolve(true), + }); + + const clientError = CONFIG_PARSE_ERROR.create({ + detail: "Hosted configuration rejected (forbidden-capability: unsupported-call)", + }); + assertEquals( + captureApplicationError(clientError, { boundary: "renderer.request" }), + undefined, + ); + assertEquals(captures, []); + + const serverError = INITIALIZATION_ERROR.create({ + detail: "renderer failed to initialize", + }); + assertEquals( + captureApplicationError(serverError, { boundary: "renderer.request" }), + "event-id", + ); + const plainError = new Error("render failed"); + assertEquals( + captureApplicationError(plainError, { boundary: "renderer.request" }), + "event-id", + ); + assertEquals(captures, [serverError, plainError]); +}); + it("application error capture failures never replace application control flow", () => { const hostile = new Proxy({}, { getPrototypeOf() { diff --git a/src/observability/application-errors.ts b/src/observability/application-errors.ts index 4fb8c2888d..d6be4129c0 100644 --- a/src/observability/application-errors.ts +++ b/src/observability/application-errors.ts @@ -1,3 +1,4 @@ +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; import { sanitizeTelemetryAttributes, sanitizeTelemetryText } from "./telemetry-error.ts"; import { MAX_APPLICATION_ERROR_CONTEXT_VALUE_LENGTH } from "./limits.ts"; @@ -265,7 +266,13 @@ export async function flushApplicationErrors(timeoutMs = 2_000): Promise= 400 && snapshot.status < 500; } catch { return false; }