Files
SnapOtter/apps/api/src/tracing.ts
T
SnapOtterandGitHub 8f4235d2c6 fix(enterprise): ship enterprise package in prod image + S3, analytics, tracing, queue fixes (#342)
* fix(enterprise): ship enterprise pkg in prod image, full license features, tracing key fallback

docker/Dockerfile: COPY packages/enterprise manifest+src into the production stage.
Without it, apps/api's workspace link to @snapotter/enterprise dangles and every
import() throws (silently caught), so all 19 enterprise features failed closed
(enterprise.active=false) regardless of a valid license.

scripts/generate-license.mjs: sync PLAN_FEATURES with packages/enterprise/src/license.ts
so a --plan enterprise license unlocks all 19 features (was 8) and team unlocks 8.

apps/api/src/tracing.ts: accept SNAPOTTER_LICENSE_KEY as a fallback to LICENSE_KEY so
distributed_tracing activates with the same key as the rest of the app.

* fix(docker): keep scripts/bake-analytics.mjs in build context

.dockerignore excluded the whole scripts/ dir (PR #82, V1 hardening), but
docker/Dockerfile later added 'COPY scripts/bake-analytics.mjs' for the analytics
bake step. A clean production image build therefore fails with
'scripts/bake-analytics.mjs: not found'. The published image build is gated off in
CI so this latent break went unnoticed. Exclude scripts/* but re-include the one
file the Dockerfile needs.

* fix: S3 upload stream, analytics bake reaches API, dedupe retention field, reconcile orphan jobs

storage-s3.ts: wrap the upload AsyncIterable in Readable.from() so @aws-sdk/lib-storage
accepts it. STORAGE_MODE=s3 file uploads failed with 'Body Data is unsupported format'
for every tool because a bare async generator is not a Readable.

docker/Dockerfile: COPY the builder-baked analytics baked.ts into the API runtime stage.
The API re-copied the committed (off) baked.ts from the build context, so the
SNAPOTTER_ANALYTICS build arg had no effect on the API -- and since the SPA reads
/api/v1/config/analytics, analytics was off everywhere regardless of the arg.

settings-dialog.tsx: remove the duplicate tempFileMaxAgeHours control under Data
Retention; it bound the same setting key as the File Management control with a different
default, so editing either silently overwrote the other.

apps/api/src/index.ts: reconcile orphaned job rows (empty tool_id, never enqueued to
BullMQ) at boot so they don't sit in processing/queued forever and inflate the per-user
concurrent-job count and the upgrade-check in-flight gate.

* fix(web): style the SSO login buttons (they referenced undefined theme tokens)

The OIDC/SAML 'Sign in with <provider>' buttons used bg-secondary /
text-secondary-foreground, which the web theme never defines (it has primary,
background, foreground, muted, border, card, primary-subtle). Those classes resolved
to nothing, so the buttons rendered as bare unstyled text on the login page.

Restyle: the optional (non-enforced) buttons become white-card outline buttons with a
key icon and an orange hover tint, secondary to the primary Login button; the
SSO-enforced buttons become solid primary with the icon.

* fix: gate S3 behind license, custom-role enterprise perms, wire retention UI, cleanup

S3 is a licensed feature, but shipping packages/enterprise in every image removed the
implicit gate, so STORAGE_MODE=s3 worked without a license. Enforce
isFeatureEnabled('s3_storage') at boot and fail fast if unlicensed.

Custom roles can now be granted security:manage / compliance:manage / webhooks:manage
(roles.ts ALL_PERMISSIONS + the Roles UI) so admins can build least-privilege
compliance/security roles instead of only the built-in admin role.

retentionSweep now reads the jobsRetentionDays / auditRetentionDays DB settings the
System Settings UI writes (env vars become the fallback default), mirroring how the
temp-file sweep reads tempFileMaxAgeHours. Previously those two UI controls were no-ops.

Cleanup: drop the never-set snapotter_storage_bytes gauge and the unused
MAX_WORKSPACE_SIZE_GB env var; emit tool_client_error to PostHog from the web
ErrorBoundary (client crashes were not reaching analytics); add the Python
OpenTelemetry packages so the innermost sidecar.<script> span exports; fix the stale
'only local storage' line in the docs; delete two e2e-analytics specs that tested the
removed consent UI.

* fix(env): restore MAX_WORKSPACE_SIZE_GB default

security-auth-hardening.test.ts asserts env.MAX_WORKSPACE_SIZE_GB defaults to 10, so
the var is an intentional (tested) default, not dead code. Removing it in the cleanup
commit broke that unit test. Keep the declaration.
2026-06-24 17:27:59 +08:00

97 lines
3.4 KiB
TypeScript

import { createRequire } from "node:module";
import type { SpanExporter } from "@opentelemetry/sdk-trace-base";
let _active = false;
let _sdk: { shutdown(): Promise<void> } | null = null;
export function isTracingActive(): boolean {
return _active;
}
export async function initTracing(options: { exporter?: SpanExporter } = {}): Promise<void> {
if (_active) return;
const { NodeSDK } = await import("@opentelemetry/sdk-node");
const { BatchSpanProcessor } = await import("@opentelemetry/sdk-trace-base");
const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http");
const { defaultResource, resourceFromAttributes } = await import("@opentelemetry/resources");
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = await import(
"@opentelemetry/semantic-conventions"
);
const { HttpInstrumentation } = await import("@opentelemetry/instrumentation-http");
const { FastifyInstrumentation } = await import("@opentelemetry/instrumentation-fastify");
const { PgInstrumentation } = await import("@opentelemetry/instrumentation-pg");
const { IORedisInstrumentation } = await import("@opentelemetry/instrumentation-ioredis");
const { AwsInstrumentation } = await import("@opentelemetry/instrumentation-aws-sdk");
const require = createRequire(import.meta.url);
const { version } = require("../package.json");
const exporter = options.exporter ?? new OTLPTraceExporter();
const resource = defaultResource().merge(
resourceFromAttributes({
[ATTR_SERVICE_NAME]: "snapotter-api",
[ATTR_SERVICE_VERSION]: version,
}),
);
const sdk = new NodeSDK({
resource,
spanProcessors: [new BatchSpanProcessor(exporter)],
instrumentations: [
new HttpInstrumentation({
ignoreOutgoingRequestHook: (req) => {
const host = req.hostname || req.host || "";
return host.includes("posthog") || host.includes("sentry");
},
}),
new FastifyInstrumentation(),
new PgInstrumentation(),
new IORedisInstrumentation({
requireParentSpan: true,
dbStatementSerializer: (cmd, args) => {
if (cmd === "evalsha" || cmd === "eval") return `${cmd} <lua>`;
return `${cmd} ${(args ?? []).slice(0, 2).join(" ")}`;
},
}),
new AwsInstrumentation(),
],
});
sdk.start();
_sdk = sdk;
_active = true;
}
export async function shutdownTracing(): Promise<void> {
if (_sdk) {
await _sdk.shutdown().catch(() => {});
_sdk = null;
_active = false;
}
}
// -- Preload entry point --
// When loaded via --import, this top-level await runs before the app.
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (endpoint) {
try {
const enterprise = await import("@snapotter/enterprise");
// The rest of the app reads SNAPOTTER_LICENSE_KEY (env.ts / index.ts). Accept
// either here so distributed_tracing activates with the same key as every other
// enterprise feature; LICENSE_KEY kept as an override for preload-only setups.
const licenseKey = process.env.LICENSE_KEY ?? process.env.SNAPOTTER_LICENSE_KEY ?? "";
if (licenseKey) {
enterprise.initEnterprise(licenseKey);
}
if (enterprise.isFeatureEnabled("distributed_tracing")) {
await initTracing();
console.log("[tracing] OpenTelemetry initialized, exporting to", endpoint);
}
} catch {
// Enterprise package not available or license invalid -- tracing stays off
}
}