@cosmicdrift/kumiko-framework 0.1.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/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { buildDrizzleTable } from "../../db/table-builder";
|
|
4
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
5
|
+
import type { AppContext, SaveContext } from "../../engine/types";
|
|
6
|
+
import { createJobRunner } from "../../jobs";
|
|
7
|
+
import { createLogger } from "../../logging/pino-logger";
|
|
8
|
+
import {
|
|
9
|
+
createEntityTable,
|
|
10
|
+
createTestRedis,
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestRedis,
|
|
13
|
+
type TestStack,
|
|
14
|
+
} from "../../stack";
|
|
15
|
+
import { createRecordingProvider, type RecordingProvider, waitFor } from "../../testing";
|
|
16
|
+
|
|
17
|
+
// End-to-end observability integration: wires a full Kumiko stack with a
|
|
18
|
+
// RecordingProvider so we can assert on the span tree and metric events.
|
|
19
|
+
// Covers every layer boundary instrumented in v1 (http → dispatcher →
|
|
20
|
+
// pipeline hooks) plus the feature-level r.metric() path.
|
|
21
|
+
|
|
22
|
+
const productFeature = defineFeature("product", (r) => {
|
|
23
|
+
r.metric("tracked_total", {
|
|
24
|
+
type: "counter",
|
|
25
|
+
labels: ["kind"],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
r.writeHandler(
|
|
29
|
+
"track",
|
|
30
|
+
z.object({ kind: z.string() }),
|
|
31
|
+
async (event, ctx) => {
|
|
32
|
+
ctx.metrics.inc("tracked_total", { kind: event.payload.kind });
|
|
33
|
+
return { isSuccess: true, data: { ok: true } };
|
|
34
|
+
},
|
|
35
|
+
{ access: { openToAll: true } },
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const errorFeature = defineFeature("err", (r) => {
|
|
40
|
+
r.writeHandler(
|
|
41
|
+
"boom",
|
|
42
|
+
z.object({}),
|
|
43
|
+
async () => {
|
|
44
|
+
throw new Error("boom from handler");
|
|
45
|
+
},
|
|
46
|
+
{ access: { openToAll: true } },
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Feature with a real DB-backed entity + custom handler + postSave hook.
|
|
51
|
+
// Exercises the full Dispatcher → DB write → Lifecycle hook chain so we can
|
|
52
|
+
// verify the db.query and kumiko.pipeline.hook spans land in the right place.
|
|
53
|
+
//
|
|
54
|
+
// Entity + Drizzle table are co-located inside the feature closure so the
|
|
55
|
+
// writeHandler can reference the table without a module-level side-effect.
|
|
56
|
+
// id, tenantId, version live in buildBaseColumns — don't redeclare them here.
|
|
57
|
+
// Declaring `tenantId: { type: "number" }` used to overwrite the base UUID
|
|
58
|
+
// column with an integer one; the insert then got a UUID-string and Postgres
|
|
59
|
+
// failed the cast, surfacing as internal_error.
|
|
60
|
+
const todoEntity = {
|
|
61
|
+
fields: {
|
|
62
|
+
title: { type: "text" as const, required: true },
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let postSaveInvocations = 0;
|
|
67
|
+
const todoFeature = defineFeature("todo", (r) => {
|
|
68
|
+
const todoTable = buildDrizzleTable("todo", todoEntity);
|
|
69
|
+
r.entity("todo", todoEntity);
|
|
70
|
+
|
|
71
|
+
r.writeHandler(
|
|
72
|
+
"create",
|
|
73
|
+
z.object({ title: z.string() }),
|
|
74
|
+
async (event, ctx) => {
|
|
75
|
+
const rows = await ctx.db
|
|
76
|
+
.insert(todoTable)
|
|
77
|
+
.values({ title: event.payload.title })
|
|
78
|
+
.returning();
|
|
79
|
+
const row = rows[0] as { id: number; title: string };
|
|
80
|
+
return {
|
|
81
|
+
isSuccess: true,
|
|
82
|
+
data: {
|
|
83
|
+
kind: "save" as const,
|
|
84
|
+
id: row.id,
|
|
85
|
+
data: row,
|
|
86
|
+
changes: { title: event.payload.title },
|
|
87
|
+
previous: {},
|
|
88
|
+
isNew: true,
|
|
89
|
+
entityName: "todo",
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
{ access: { openToAll: true } },
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
r.hook("postSave", "create", async (_save: SaveContext) => {
|
|
97
|
+
postSaveInvocations++;
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const adminUser = {
|
|
102
|
+
id: "11111111-0000-4000-8000-000000000001",
|
|
103
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
104
|
+
roles: ["admin"] as const,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
describe("Observability (integration)", () => {
|
|
108
|
+
let stack: TestStack;
|
|
109
|
+
let provider: RecordingProvider;
|
|
110
|
+
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
provider = createRecordingProvider();
|
|
113
|
+
stack = await setupTestStack({
|
|
114
|
+
features: [productFeature],
|
|
115
|
+
observability: provider,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(async () => {
|
|
120
|
+
await stack.cleanup();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("emits a root http.request span with request-id attribute", async () => {
|
|
124
|
+
await stack.http.command("product:write:track", { kind: "a" }, adminUser);
|
|
125
|
+
|
|
126
|
+
const httpSpans = provider.spansByName("http.request");
|
|
127
|
+
expect(httpSpans.length).toBeGreaterThanOrEqual(1);
|
|
128
|
+
const httpSpan = httpSpans[0]!;
|
|
129
|
+
expect(httpSpan.parentSpanId).toBeUndefined();
|
|
130
|
+
expect(httpSpan.attributes["http.method"]).toBe("POST");
|
|
131
|
+
expect(typeof httpSpan.attributes["kumiko.request_id"]).toBe("string");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("nests dispatcher.handler under http.request using the same traceId", async () => {
|
|
135
|
+
await stack.http.command("product:write:track", { kind: "a" }, adminUser);
|
|
136
|
+
|
|
137
|
+
const httpSpan = provider.spansByName("http.request")[0]!;
|
|
138
|
+
const dispatcherSpans = provider.spansByName("kumiko.dispatcher.handler");
|
|
139
|
+
expect(dispatcherSpans.length).toBeGreaterThanOrEqual(1);
|
|
140
|
+
const dispatcherSpan = dispatcherSpans[0]!;
|
|
141
|
+
expect(dispatcherSpan.traceId).toBe(httpSpan.traceId);
|
|
142
|
+
expect(dispatcherSpan.parentSpanId).toBe(httpSpan.spanId);
|
|
143
|
+
expect(dispatcherSpan.attributes["kumiko.handler"]).toBe("product:write:track");
|
|
144
|
+
expect(dispatcherSpan.attributes["kumiko.feature"]).toBe("product");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("feature metric ctx.metrics.inc emits counter.inc with feature prefix", async () => {
|
|
148
|
+
await stack.http.command("product:write:track", { kind: "premium" }, adminUser);
|
|
149
|
+
|
|
150
|
+
const counterEvents = provider.metricEvents.filter(
|
|
151
|
+
(e) => e.type === "counter.inc" && e.name === "kumiko_product_tracked_total",
|
|
152
|
+
);
|
|
153
|
+
expect(counterEvents).toHaveLength(1);
|
|
154
|
+
expect(counterEvents[0]?.labels).toEqual({ kind: "premium" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("emits standard http + dispatcher metrics for the request", async () => {
|
|
158
|
+
await stack.http.command("product:write:track", { kind: "a" }, adminUser);
|
|
159
|
+
|
|
160
|
+
const httpRequestsTotal = provider.metricEvents.find(
|
|
161
|
+
(e) => e.type === "counter.inc" && e.name === "kumiko_http_requests_total",
|
|
162
|
+
);
|
|
163
|
+
expect(httpRequestsTotal).toBeDefined();
|
|
164
|
+
expect(httpRequestsTotal?.labels?.["method"]).toBe("POST");
|
|
165
|
+
|
|
166
|
+
const handlerDuration = provider.metricEvents.find(
|
|
167
|
+
(e) =>
|
|
168
|
+
e.type === "histogram.observe" && e.name === "kumiko_dispatcher_handler_duration_seconds",
|
|
169
|
+
);
|
|
170
|
+
expect(handlerDuration).toBeDefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("sensitive Authorization header does not leak into any span", async () => {
|
|
174
|
+
await stack.http.command("product:write:track", { kind: "a" }, adminUser);
|
|
175
|
+
|
|
176
|
+
const allAttributeValues = provider.spans
|
|
177
|
+
.flatMap((s) => Object.values(s.attributes))
|
|
178
|
+
.map((v) => String(v));
|
|
179
|
+
for (const v of allAttributeValues) {
|
|
180
|
+
expect(v).not.toMatch(/^Bearer /i);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("Observability (integration) — DB + pipeline hook spans", () => {
|
|
186
|
+
let stack: TestStack;
|
|
187
|
+
let provider: RecordingProvider;
|
|
188
|
+
|
|
189
|
+
beforeEach(async () => {
|
|
190
|
+
postSaveInvocations = 0;
|
|
191
|
+
provider = createRecordingProvider();
|
|
192
|
+
stack = await setupTestStack({
|
|
193
|
+
features: [todoFeature],
|
|
194
|
+
observability: provider,
|
|
195
|
+
systemHooks: [],
|
|
196
|
+
});
|
|
197
|
+
await createEntityTable(stack.db, todoEntity, "todo");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
afterEach(async () => {
|
|
201
|
+
await stack.cleanup();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("emits db.query spans under the dispatcher span with operation + table attrs", async () => {
|
|
205
|
+
await stack.http.writeOk("todo:write:create", { title: "buy milk" }, adminUser);
|
|
206
|
+
|
|
207
|
+
const httpSpan = provider.spansByName("http.request")[0]!;
|
|
208
|
+
const dispatcherSpan = provider.spansByName("kumiko.dispatcher.handler")[0]!;
|
|
209
|
+
const dbSpans = provider.spansByName("db.query");
|
|
210
|
+
|
|
211
|
+
expect(dbSpans.length).toBeGreaterThanOrEqual(1);
|
|
212
|
+
// At least one db.query should be a descendant of the dispatcher span.
|
|
213
|
+
const insertSpan = dbSpans.find((s) => s.attributes["db.operation"] === "insert");
|
|
214
|
+
expect(insertSpan).toBeDefined();
|
|
215
|
+
expect(insertSpan?.traceId).toBe(httpSpan.traceId);
|
|
216
|
+
expect(insertSpan?.attributes["db.table"]).toBe("read_todos");
|
|
217
|
+
expect(insertSpan?.attributes["db.system"]).toBe("postgresql");
|
|
218
|
+
// parent chain: insert → ... → dispatcher
|
|
219
|
+
const dispatcherId = dispatcherSpan.spanId;
|
|
220
|
+
const allInTrace = provider.spansByTraceId(httpSpan.traceId);
|
|
221
|
+
const idToSpan = new Map(allInTrace.map((s) => [s.spanId, s]));
|
|
222
|
+
let cursor: string | undefined = insertSpan?.parentSpanId;
|
|
223
|
+
let foundDispatcher = false;
|
|
224
|
+
while (cursor) {
|
|
225
|
+
if (cursor === dispatcherId) {
|
|
226
|
+
foundDispatcher = true;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
cursor = idToSpan.get(cursor)?.parentSpanId;
|
|
230
|
+
}
|
|
231
|
+
expect(foundDispatcher).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("emits db.query metric with operation + table labels", async () => {
|
|
235
|
+
await stack.http.writeOk("todo:write:create", { title: "ship it" }, adminUser);
|
|
236
|
+
|
|
237
|
+
const dbMetric = provider.metricEvents.find(
|
|
238
|
+
(e) =>
|
|
239
|
+
e.type === "histogram.observe" &&
|
|
240
|
+
e.name === "kumiko_db_query_duration_seconds" &&
|
|
241
|
+
e.labels?.["operation"] === "insert" &&
|
|
242
|
+
e.labels?.["table"] === "read_todos",
|
|
243
|
+
);
|
|
244
|
+
expect(dbMetric).toBeDefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("emits kumiko.pipeline.hook span under the dispatcher for the postSave hook", async () => {
|
|
248
|
+
await stack.http.writeOk("todo:write:create", { title: "test hook span" }, adminUser);
|
|
249
|
+
expect(postSaveInvocations).toBeGreaterThanOrEqual(1);
|
|
250
|
+
|
|
251
|
+
const httpSpan = provider.spansByName("http.request")[0]!;
|
|
252
|
+
const hookSpans = provider.spansByName("kumiko.pipeline.hook");
|
|
253
|
+
expect(hookSpans.length).toBeGreaterThanOrEqual(1);
|
|
254
|
+
|
|
255
|
+
// Every hook span should belong to the same trace and carry the standard
|
|
256
|
+
// attributes so dashboards can filter by handler / phase / source.
|
|
257
|
+
for (const hookSpan of hookSpans) {
|
|
258
|
+
expect(hookSpan.traceId).toBe(httpSpan.traceId);
|
|
259
|
+
expect(hookSpan.attributes["kumiko.handler"]).toBe("todo:write:create");
|
|
260
|
+
expect(typeof hookSpan.attributes["kumiko.hook_type"]).toBe("string");
|
|
261
|
+
expect(typeof hookSpan.attributes["kumiko.hook_phase"]).toBe("string");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// At least one handler-sourced hook should exist (the postSave we declared).
|
|
265
|
+
const handlerHook = hookSpans.find((s) => s.attributes["kumiko.hook_source"] === "handler");
|
|
266
|
+
expect(handlerHook).toBeDefined();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// outbox cross-process trace propagation lived here once — removed in D.5 when
|
|
271
|
+
// the outbox was replaced by the async event-dispatcher. Cross-consumer trace
|
|
272
|
+
// continuation for the new pipeline is tested in event-dispatcher.integration.
|
|
273
|
+
|
|
274
|
+
// Redis-wrapper instrumentation: any command issued through the Redis client
|
|
275
|
+
// that arrives in the AppContext emits a `redis.cmd` span with command name
|
|
276
|
+
// and a key pattern (never the raw key).
|
|
277
|
+
describe("Observability (integration) — Redis wrapper", () => {
|
|
278
|
+
let stack: TestStack;
|
|
279
|
+
let provider: RecordingProvider;
|
|
280
|
+
|
|
281
|
+
const redisFeature = defineFeature("redis-cmds", (r) => {
|
|
282
|
+
r.writeHandler(
|
|
283
|
+
"ping",
|
|
284
|
+
z.object({}),
|
|
285
|
+
async (_event, ctx) => {
|
|
286
|
+
if (!ctx.redis) throw new Error("ctx.redis unavailable");
|
|
287
|
+
await ctx.redis.set("session:abc123:token", "value");
|
|
288
|
+
await ctx.redis.get("session:abc123:token");
|
|
289
|
+
return { isSuccess: true, data: { ok: true } };
|
|
290
|
+
},
|
|
291
|
+
{ access: { openToAll: true } },
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
beforeEach(async () => {
|
|
296
|
+
provider = createRecordingProvider();
|
|
297
|
+
stack = await setupTestStack({
|
|
298
|
+
features: [redisFeature],
|
|
299
|
+
observability: provider,
|
|
300
|
+
systemHooks: [],
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
afterEach(async () => {
|
|
305
|
+
await stack.cleanup();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("emits redis.cmd spans for set + get, redacts raw keys to a pattern", async () => {
|
|
309
|
+
await stack.http.writeOk("redis-cmds:write:ping", {}, adminUser);
|
|
310
|
+
|
|
311
|
+
const redisSpans = provider.spansByName("redis.cmd");
|
|
312
|
+
// Exactly the two commands the handler issued.
|
|
313
|
+
const commands = redisSpans
|
|
314
|
+
.map((s) => s.attributes["redis.command"])
|
|
315
|
+
.filter((c): c is string => typeof c === "string");
|
|
316
|
+
expect(commands).toContain("set");
|
|
317
|
+
expect(commands).toContain("get");
|
|
318
|
+
|
|
319
|
+
// Key pattern is the safe `namespace:second-segment:*` form, never the
|
|
320
|
+
// full session token.
|
|
321
|
+
for (const s of redisSpans) {
|
|
322
|
+
const pattern = s.attributes["redis.key_pattern"];
|
|
323
|
+
if (pattern !== undefined) {
|
|
324
|
+
expect(String(pattern)).toBe("session:abc123:*");
|
|
325
|
+
expect(String(pattern)).not.toContain("token");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Same trace as the http.request span.
|
|
330
|
+
const httpSpan = provider.spansByName("http.request")[0]!;
|
|
331
|
+
for (const s of redisSpans) {
|
|
332
|
+
expect(s.traceId).toBe(httpSpan.traceId);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Pino bridge: ctx.log entries emitted through the real createLogger()
|
|
338
|
+
// automatically carry the active trace context (traceId + spanId). Genuine
|
|
339
|
+
// end-to-end means: give createLogger a custom destination stream, inject
|
|
340
|
+
// it via extraContext, fire an HTTP request that calls ctx.log inside a
|
|
341
|
+
// handler, then parse the captured NDJSON and verify trace fields landed.
|
|
342
|
+
describe("Observability (integration) — Pino trace bridge", () => {
|
|
343
|
+
let stack: TestStack;
|
|
344
|
+
let provider: RecordingProvider;
|
|
345
|
+
let capturedLines: string[];
|
|
346
|
+
|
|
347
|
+
const logFeature = defineFeature("pino-bridge", (r) => {
|
|
348
|
+
r.writeHandler(
|
|
349
|
+
"say",
|
|
350
|
+
z.object({ msg: z.string() }),
|
|
351
|
+
async (event, ctx) => {
|
|
352
|
+
ctx.log?.info(event.payload.msg, { custom: "field" });
|
|
353
|
+
return { isSuccess: true, data: { ok: true } };
|
|
354
|
+
},
|
|
355
|
+
{ access: { openToAll: true } },
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
beforeEach(async () => {
|
|
360
|
+
capturedLines = [];
|
|
361
|
+
provider = createRecordingProvider();
|
|
362
|
+
// Pino writes NDJSON; one call = one line. Keep the raw chunks so we can
|
|
363
|
+
// both assert on the bytes AND parse them back to objects.
|
|
364
|
+
const destination = {
|
|
365
|
+
write: (chunk: string) => {
|
|
366
|
+
capturedLines.push(chunk);
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
const realLogger = createLogger({ level: "info", destination });
|
|
370
|
+
stack = await setupTestStack({
|
|
371
|
+
features: [logFeature],
|
|
372
|
+
observability: provider,
|
|
373
|
+
systemHooks: [],
|
|
374
|
+
extraContext: { log: realLogger },
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
afterEach(async () => {
|
|
379
|
+
await stack.cleanup();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("real createLogger emits NDJSON with traceId/spanId matching the active span", async () => {
|
|
383
|
+
await stack.http.writeOk("pino-bridge:write:say", { msg: "hello" }, adminUser);
|
|
384
|
+
|
|
385
|
+
// Find the NDJSON line pino wrote for our handler log.
|
|
386
|
+
const parsed = capturedLines
|
|
387
|
+
.flatMap((c) => c.split("\n"))
|
|
388
|
+
.filter((l) => l.trim().length > 0)
|
|
389
|
+
.map((l) => JSON.parse(l));
|
|
390
|
+
const entry = parsed.find((p) => p["msg"] === "hello");
|
|
391
|
+
expect(entry).toBeDefined();
|
|
392
|
+
expect(entry?.["custom"]).toBe("field");
|
|
393
|
+
expect(typeof entry?.["traceId"]).toBe("string");
|
|
394
|
+
expect(typeof entry?.["spanId"]).toBe("string");
|
|
395
|
+
|
|
396
|
+
const httpSpan = provider.spansByName("http.request")[0]!;
|
|
397
|
+
expect(entry?.["traceId"]).toBe(httpSpan.traceId);
|
|
398
|
+
|
|
399
|
+
// spanId should match one of the spans in the same trace (most specific
|
|
400
|
+
// active span when ctx.log is called — typically the dispatcher span).
|
|
401
|
+
const idsInTrace = provider.spansByTraceId(httpSpan.traceId).map((s) => s.spanId);
|
|
402
|
+
expect(idsInTrace).toContain(entry?.["spanId"]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("logs outside any active span have no trace fields", () => {
|
|
406
|
+
const lines: string[] = [];
|
|
407
|
+
const destination = {
|
|
408
|
+
write: (chunk: string) => {
|
|
409
|
+
lines.push(chunk);
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
const logger = createLogger({ level: "info", destination });
|
|
413
|
+
logger.info("standalone");
|
|
414
|
+
|
|
415
|
+
const parsed = lines
|
|
416
|
+
.flatMap((c) => c.split("\n"))
|
|
417
|
+
.filter((l) => l.trim().length > 0)
|
|
418
|
+
.map((l) => JSON.parse(l));
|
|
419
|
+
const entry = parsed.find((p) => p["msg"] === "standalone");
|
|
420
|
+
expect(entry).toBeDefined();
|
|
421
|
+
expect(entry?.["traceId"]).toBeUndefined();
|
|
422
|
+
expect(entry?.["spanId"]).toBeUndefined();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Jobs cross-process trace: a handler (or any caller) dispatches a job while
|
|
427
|
+
// inside an active span. The job payload carries a serialized trace context;
|
|
428
|
+
// when the worker picks up the job, the `job.execute` span must land in the
|
|
429
|
+
// SAME trace as the dispatcher's parent span.
|
|
430
|
+
describe("Observability (integration) — Jobs cross-process trace", () => {
|
|
431
|
+
let testRedis: TestRedis;
|
|
432
|
+
let redisUrl: string;
|
|
433
|
+
|
|
434
|
+
// Track which job runs so we can await completion before asserting spans.
|
|
435
|
+
let jobRanWith: Record<string, unknown> | undefined;
|
|
436
|
+
|
|
437
|
+
const jobFeature = defineFeature("jobs-trace", (r) => {
|
|
438
|
+
r.job("record", { trigger: { manual: true } }, async (payload) => {
|
|
439
|
+
jobRanWith = payload;
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
beforeAll(async () => {
|
|
444
|
+
testRedis = await createTestRedis();
|
|
445
|
+
const { host, port, db } = testRedis.redis.options;
|
|
446
|
+
redisUrl = `redis://${host}:${port}/${db ?? 0}`;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
afterAll(async () => {
|
|
450
|
+
await testRedis.cleanup();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
beforeEach(() => {
|
|
454
|
+
jobRanWith = undefined;
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("job.execute span shares the caller's traceId and parents on the caller's span", async () => {
|
|
458
|
+
const provider = createRecordingProvider();
|
|
459
|
+
const registry = createRegistry([jobFeature]);
|
|
460
|
+
const context: AppContext = { tracer: provider.tracer, meter: provider.meter };
|
|
461
|
+
const runner = createJobRunner({
|
|
462
|
+
registry,
|
|
463
|
+
context,
|
|
464
|
+
redisUrl,
|
|
465
|
+
consumerLane: "worker",
|
|
466
|
+
queueNamePrefix: `kumiko-obs-${Date.now()}`,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
await runner.start();
|
|
471
|
+
|
|
472
|
+
// Dispatch the job from inside an active span — this is the caller that
|
|
473
|
+
// the worker must link back to via the serialized trace context.
|
|
474
|
+
const dispatched = await provider.tracer.withSpan("caller.request", {}, async () => {
|
|
475
|
+
await runner.dispatch("jobs-trace:job:record", { note: "hi" });
|
|
476
|
+
return provider.tracer.getActiveSpan()!;
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
await waitFor(() => {
|
|
480
|
+
if (jobRanWith === undefined) throw new Error("job didn't run yet");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// The caller span — recorded when withSpan ends — is the parent target.
|
|
484
|
+
const callerSpan = provider.spansByName("caller.request")[0]!;
|
|
485
|
+
const jobSpan = provider.spansByName("job.execute")[0]!;
|
|
486
|
+
|
|
487
|
+
expect(jobSpan).toBeDefined();
|
|
488
|
+
expect(jobSpan.traceId).toBe(callerSpan.traceId);
|
|
489
|
+
expect(jobSpan.parentSpanId).toBe(dispatched.spanId);
|
|
490
|
+
expect(jobSpan.attributes["job.name"]).toBe("jobs-trace:job:record");
|
|
491
|
+
// Welle 2.6: lane-routing attributes. run_in is the job's declared
|
|
492
|
+
// lane (default "worker" here, no explicit runIn on the feature);
|
|
493
|
+
// consumer_lane is the runner that actually picked it.
|
|
494
|
+
expect(jobSpan.attributes["kumiko.job.run_in"]).toBe("worker");
|
|
495
|
+
expect(jobSpan.attributes["kumiko.job.consumer_lane"]).toBe("worker");
|
|
496
|
+
} finally {
|
|
497
|
+
await runner.stop();
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("job.execute is a root span when dispatched without an active caller span", async () => {
|
|
502
|
+
const provider = createRecordingProvider();
|
|
503
|
+
const registry = createRegistry([jobFeature]);
|
|
504
|
+
const context: AppContext = { tracer: provider.tracer, meter: provider.meter };
|
|
505
|
+
const runner = createJobRunner({
|
|
506
|
+
registry,
|
|
507
|
+
context,
|
|
508
|
+
redisUrl,
|
|
509
|
+
consumerLane: "worker",
|
|
510
|
+
queueNamePrefix: `kumiko-obs-${Date.now()}`,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
await runner.start();
|
|
515
|
+
await runner.dispatch("jobs-trace:job:record", { note: "root" });
|
|
516
|
+
await waitFor(() => {
|
|
517
|
+
if (jobRanWith === undefined) throw new Error("job didn't run yet");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const jobSpan = provider.spansByName("job.execute")[0]!;
|
|
521
|
+
expect(jobSpan).toBeDefined();
|
|
522
|
+
// No caller span was active — the worker starts a fresh trace.
|
|
523
|
+
expect(jobSpan.parentSpanId).toBeUndefined();
|
|
524
|
+
} finally {
|
|
525
|
+
await runner.stop();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("Observability (integration) — error path", () => {
|
|
531
|
+
let stack: TestStack;
|
|
532
|
+
let provider: RecordingProvider;
|
|
533
|
+
|
|
534
|
+
beforeEach(async () => {
|
|
535
|
+
provider = createRecordingProvider();
|
|
536
|
+
stack = await setupTestStack({
|
|
537
|
+
features: [errorFeature],
|
|
538
|
+
observability: provider,
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
afterEach(async () => {
|
|
543
|
+
await stack.cleanup();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("records error status on dispatcher span + emits error counter", async () => {
|
|
547
|
+
const res = await stack.http.command("err:write:boom", {}, adminUser);
|
|
548
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
549
|
+
|
|
550
|
+
const dispatcherSpan = provider.spansByName("kumiko.dispatcher.handler")[0];
|
|
551
|
+
expect(dispatcherSpan?.status).toBe("error");
|
|
552
|
+
|
|
553
|
+
const errorCounter = provider.metricEvents.find(
|
|
554
|
+
(e) => e.type === "counter.inc" && e.name === "kumiko_dispatcher_handler_errors_total",
|
|
555
|
+
);
|
|
556
|
+
expect(errorCounter).toBeDefined();
|
|
557
|
+
expect(errorCounter?.labels?.["handler"]).toBe("err:write:boom");
|
|
558
|
+
});
|
|
559
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { createPrometheusMeter, serializeOpenMetrics } from "../prometheus-meter";
|
|
3
|
+
|
|
4
|
+
describe("PrometheusMeter — accumulation", () => {
|
|
5
|
+
test("counter: inc across multiple calls sums into a single slot", () => {
|
|
6
|
+
const meter = createPrometheusMeter();
|
|
7
|
+
meter.registerMetric({
|
|
8
|
+
name: "kumiko_test_total",
|
|
9
|
+
type: "counter",
|
|
10
|
+
description: "test counter",
|
|
11
|
+
});
|
|
12
|
+
meter.counter("kumiko_test_total").inc();
|
|
13
|
+
meter.counter("kumiko_test_total").inc(4);
|
|
14
|
+
meter.counter("kumiko_test_total").inc(0.5);
|
|
15
|
+
|
|
16
|
+
const out = serializeOpenMetrics(meter);
|
|
17
|
+
expect(out).toContain("# TYPE kumiko_test_total counter");
|
|
18
|
+
expect(out).toContain("kumiko_test_total 5.5");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("counter: different labelsets accumulate independently", () => {
|
|
22
|
+
const meter = createPrometheusMeter();
|
|
23
|
+
meter.registerMetric({
|
|
24
|
+
name: "kumiko_http_requests_total",
|
|
25
|
+
type: "counter",
|
|
26
|
+
labels: ["method", "status"],
|
|
27
|
+
});
|
|
28
|
+
const c = meter.counter("kumiko_http_requests_total");
|
|
29
|
+
c.inc(1, { method: "GET", status: "200" });
|
|
30
|
+
c.inc(1, { method: "GET", status: "200" });
|
|
31
|
+
c.inc(1, { method: "POST", status: "201" });
|
|
32
|
+
|
|
33
|
+
const out = serializeOpenMetrics(meter);
|
|
34
|
+
expect(out).toContain(`kumiko_http_requests_total{method="GET",status="200"} 2`);
|
|
35
|
+
expect(out).toContain(`kumiko_http_requests_total{method="POST",status="201"} 1`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("gauge: set overrides, inc/dec relative", () => {
|
|
39
|
+
const meter = createPrometheusMeter();
|
|
40
|
+
meter.registerMetric({ name: "kumiko_queue_depth", type: "gauge" });
|
|
41
|
+
const g = meter.gauge("kumiko_queue_depth");
|
|
42
|
+
g.set(10);
|
|
43
|
+
g.inc(5);
|
|
44
|
+
g.dec(3);
|
|
45
|
+
expect(serializeOpenMetrics(meter)).toContain("kumiko_queue_depth 12");
|
|
46
|
+
|
|
47
|
+
g.set(0);
|
|
48
|
+
expect(serializeOpenMetrics(meter)).toContain("kumiko_queue_depth 0");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("histogram: cumulative buckets + sum + count, +Inf terminator", () => {
|
|
52
|
+
const meter = createPrometheusMeter();
|
|
53
|
+
meter.registerMetric({
|
|
54
|
+
name: "kumiko_latency_seconds",
|
|
55
|
+
type: "histogram",
|
|
56
|
+
buckets: [0.01, 0.1, 1],
|
|
57
|
+
});
|
|
58
|
+
const h = meter.histogram("kumiko_latency_seconds");
|
|
59
|
+
h.observe(0.005); // hits 0.01, 0.1, 1
|
|
60
|
+
h.observe(0.5); // hits 1 only
|
|
61
|
+
h.observe(2); // hits nothing but count
|
|
62
|
+
|
|
63
|
+
const out = serializeOpenMetrics(meter);
|
|
64
|
+
expect(out).toContain(`kumiko_latency_seconds_bucket{le="0.01"} 1`);
|
|
65
|
+
expect(out).toContain(`kumiko_latency_seconds_bucket{le="0.1"} 1`);
|
|
66
|
+
expect(out).toContain(`kumiko_latency_seconds_bucket{le="1"} 2`);
|
|
67
|
+
expect(out).toContain(`kumiko_latency_seconds_bucket{le="+Inf"} 3`);
|
|
68
|
+
expect(out).toContain(`kumiko_latency_seconds_sum 2.505`);
|
|
69
|
+
expect(out).toContain(`kumiko_latency_seconds_count 3`);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("serializeOpenMetrics — format invariants", () => {
|
|
74
|
+
test("output ends with # EOF + newline (OpenMetrics spec)", () => {
|
|
75
|
+
const meter = createPrometheusMeter();
|
|
76
|
+
meter.registerMetric({ name: "kumiko_noop", type: "counter" });
|
|
77
|
+
const out = serializeOpenMetrics(meter);
|
|
78
|
+
expect(out.endsWith("# EOF\n")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("HELP line emitted when description is set, skipped otherwise", () => {
|
|
82
|
+
const meter = createPrometheusMeter();
|
|
83
|
+
meter.registerMetric({
|
|
84
|
+
name: "kumiko_with_help",
|
|
85
|
+
type: "counter",
|
|
86
|
+
description: "documented",
|
|
87
|
+
});
|
|
88
|
+
meter.registerMetric({ name: "kumiko_no_help", type: "counter" });
|
|
89
|
+
|
|
90
|
+
const out = serializeOpenMetrics(meter);
|
|
91
|
+
expect(out).toContain("# HELP kumiko_with_help documented");
|
|
92
|
+
expect(out).not.toContain("# HELP kumiko_no_help");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("labels get quoted, escape special chars (backslash, quote, newline)", () => {
|
|
96
|
+
const meter = createPrometheusMeter();
|
|
97
|
+
meter.registerMetric({
|
|
98
|
+
name: "kumiko_log",
|
|
99
|
+
type: "counter",
|
|
100
|
+
labels: ["msg"],
|
|
101
|
+
});
|
|
102
|
+
meter.counter("kumiko_log").inc(1, { msg: 'she said "hi"\nback\\slash' });
|
|
103
|
+
const out = serializeOpenMetrics(meter);
|
|
104
|
+
expect(out).toContain(`kumiko_log{msg="she said \\"hi\\"\\nback\\\\slash"} 1`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("label keys are sorted alphabetically for deterministic output", () => {
|
|
108
|
+
const meter = createPrometheusMeter();
|
|
109
|
+
meter.registerMetric({
|
|
110
|
+
name: "kumiko_req",
|
|
111
|
+
type: "counter",
|
|
112
|
+
labels: ["zulu", "alpha", "mike"],
|
|
113
|
+
});
|
|
114
|
+
meter.counter("kumiko_req").inc(1, { zulu: "z", alpha: "a", mike: "m" });
|
|
115
|
+
const out = serializeOpenMetrics(meter);
|
|
116
|
+
expect(out).toContain(`kumiko_req{alpha="a",mike="m",zulu="z"} 1`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("metric names sorted alphabetically across the output", () => {
|
|
120
|
+
const meter = createPrometheusMeter();
|
|
121
|
+
meter.registerMetric({ name: "kumiko_zebra", type: "counter" });
|
|
122
|
+
meter.registerMetric({ name: "kumiko_apple", type: "counter" });
|
|
123
|
+
meter.counter("kumiko_zebra").inc();
|
|
124
|
+
meter.counter("kumiko_apple").inc();
|
|
125
|
+
const out = serializeOpenMetrics(meter);
|
|
126
|
+
expect(out.indexOf("kumiko_apple")).toBeLessThan(out.indexOf("kumiko_zebra"));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("PrometheusMeter — registration guards", () => {
|
|
131
|
+
test("duplicate name throws", () => {
|
|
132
|
+
const meter = createPrometheusMeter();
|
|
133
|
+
meter.registerMetric({ name: "kumiko_dup", type: "counter" });
|
|
134
|
+
expect(() => meter.registerMetric({ name: "kumiko_dup", type: "gauge" })).toThrow(
|
|
135
|
+
/already registered/i,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("accessor with wrong type throws", () => {
|
|
140
|
+
const meter = createPrometheusMeter();
|
|
141
|
+
meter.registerMetric({ name: "kumiko_c", type: "counter" });
|
|
142
|
+
expect(() => meter.gauge("kumiko_c")).toThrow(/not registered or wrong type/i);
|
|
143
|
+
});
|
|
144
|
+
});
|