@monster-orange/den 0.0.0-stage → 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/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +166 -2
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +104 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-tooling telemetry emitter (native TypeScript).
|
|
3
|
+
*
|
|
4
|
+
* Den is a native consumer of common-iac's dev-metrics pipeline (like stack's
|
|
5
|
+
* Go `internal/devmetrics`): it emits the SAME telemetry contract in-process
|
|
6
|
+
* rather than shelling out to the vendored Python/bash emitters — see
|
|
7
|
+
* common-iac docs/guides/dev-metrics/README.md, "Appendix: per-tool
|
|
8
|
+
* differentiation".
|
|
9
|
+
*
|
|
10
|
+
* Contract (kept byte-compatible with dev_metrics.py — pinned by
|
|
11
|
+
* dev-metrics.emit.test.ts against golden envelopes captured via
|
|
12
|
+
* DEV_METRICS_ENVELOPE_OUT):
|
|
13
|
+
* 1. ALWAYS append one compact JSON line to <repoRoot>/.dev-metrics/events.jsonl.
|
|
14
|
+
* 2. THEN best-effort ship to Sentry (Tier-1 -> log item, Tier-2 -> event item).
|
|
15
|
+
*
|
|
16
|
+
* Guarantees: never throws, never blocks the caller (the Sentry ship is
|
|
17
|
+
* fire-and-forget, bounded to ~2s). CI=true / DEV_METRICS_DISABLED=1 suppress
|
|
18
|
+
* shipping but still buffer the JSONL locally.
|
|
19
|
+
*
|
|
20
|
+
* Tool differentiation: den emits `devtool.den.<event>` with a `tool=den`
|
|
21
|
+
* attribute. Per the appendix, event names are FIXED (`devtool.den.invocation`
|
|
22
|
+
* / `.failed`) and dimensions (command, surface, duration_ms, exit) ride as
|
|
23
|
+
* attributes — never per-command event names.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as fs from "fs";
|
|
27
|
+
import * as path from "path";
|
|
28
|
+
import { execFileSync } from "child_process";
|
|
29
|
+
import { createHash, randomUUID } from "crypto";
|
|
30
|
+
import { getDistRoot } from "../paths.js";
|
|
31
|
+
|
|
32
|
+
import { product } from "../product.js";
|
|
33
|
+
const SERVICE = "dev-tooling";
|
|
34
|
+
const ENVIRONMENT = "dev-tooling";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Alertable events, hardcoded identically to dev_metrics.py's TIER2_EVENTS
|
|
38
|
+
* (v5.0.2). Keep in lockstep with common-iac — a divergence breaks the `tier`
|
|
39
|
+
* field's byte-compatibility. `devtool.den.*` are deliberately absent, so
|
|
40
|
+
* they ship as Tier-1 logs until common-iac adds them to its allowlist.
|
|
41
|
+
*/
|
|
42
|
+
const TIER2_EVENTS = new Set<string>([
|
|
43
|
+
"devtool.pin.drift_detected",
|
|
44
|
+
"devtool.install_fix.ran",
|
|
45
|
+
"devtool.file_link.leaked",
|
|
46
|
+
"devtool.setup.failed",
|
|
47
|
+
"devtool.install.failed",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
51
|
+
const SHIP_TIMEOUT_MS = 2000;
|
|
52
|
+
|
|
53
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function git(args: string[], cwd: string): string {
|
|
56
|
+
try {
|
|
57
|
+
return execFileSync("git", args, {
|
|
58
|
+
cwd,
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
timeout: GIT_TIMEOUT_MS,
|
|
61
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
62
|
+
}).trim();
|
|
63
|
+
} catch {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function repoRoot(): string {
|
|
69
|
+
return process.env.DEV_METRICS_REPO_ROOT || getDistRoot();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Stable-per-process common fields (repo/user never change); branch/sha re-read cheaply. */
|
|
73
|
+
let _stable: { root: string; repo: string; user: string; pins: string } | null =
|
|
74
|
+
null;
|
|
75
|
+
|
|
76
|
+
function stableFields(root: string): {
|
|
77
|
+
repo: string;
|
|
78
|
+
user: string;
|
|
79
|
+
pins: string;
|
|
80
|
+
} {
|
|
81
|
+
if (_stable && _stable.root === root) return _stable;
|
|
82
|
+
const user =
|
|
83
|
+
git(["config", "user.name"], root) || process.env.USER || "unknown";
|
|
84
|
+
let pins = "";
|
|
85
|
+
try {
|
|
86
|
+
const data = fs.readFileSync(path.join(root, "api-dependencies.yml"));
|
|
87
|
+
// sha256[:12] — matches dev_metrics.py _pins_fingerprint.
|
|
88
|
+
pins = createHash("sha256").update(data).digest("hex").slice(0, 12);
|
|
89
|
+
} catch {
|
|
90
|
+
pins = "";
|
|
91
|
+
}
|
|
92
|
+
_stable = { root, repo: path.basename(root), user, pins };
|
|
93
|
+
return _stable;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function commonFields(event: string, root: string): Record<string, string> {
|
|
97
|
+
const { repo, user, pins } = stableFields(root);
|
|
98
|
+
return {
|
|
99
|
+
event,
|
|
100
|
+
tier: TIER2_EVENTS.has(event) ? "event" : "log",
|
|
101
|
+
service: SERVICE,
|
|
102
|
+
environment: ENVIRONMENT,
|
|
103
|
+
repo,
|
|
104
|
+
user,
|
|
105
|
+
branch: git(["rev-parse", "--abbrev-ref", "HEAD"], root),
|
|
106
|
+
sha: git(["rev-parse", "--short", "HEAD"], root),
|
|
107
|
+
pins,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isoNoMillis(): string {
|
|
112
|
+
return new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function suppressShip(): boolean {
|
|
116
|
+
return process.env.CI === "true" || process.env.DEV_METRICS_DISABLED === "1";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveDsn(root: string): string {
|
|
120
|
+
const env = process.env.DEV_METRICS_SENTRY_DSN;
|
|
121
|
+
if (env) return env;
|
|
122
|
+
try {
|
|
123
|
+
const cfg = fs.readFileSync(
|
|
124
|
+
path.join(
|
|
125
|
+
root,
|
|
126
|
+
".stack",
|
|
127
|
+
"common-iac",
|
|
128
|
+
"scripts",
|
|
129
|
+
"lib",
|
|
130
|
+
"dev-metrics.config",
|
|
131
|
+
),
|
|
132
|
+
"utf-8",
|
|
133
|
+
);
|
|
134
|
+
for (const line of cfg.split("\n")) {
|
|
135
|
+
if (line.startsWith("DEV_METRICS_SENTRY_DSN=")) {
|
|
136
|
+
return line
|
|
137
|
+
.slice("DEV_METRICS_SENTRY_DSN=".length)
|
|
138
|
+
.trim()
|
|
139
|
+
.replace(/^"|"$/g, "");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
/* not synced */
|
|
144
|
+
}
|
|
145
|
+
return "";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseDsn(
|
|
149
|
+
dsn: string,
|
|
150
|
+
): { scheme: string; host: string; key: string; projectId: string } | null {
|
|
151
|
+
const m = dsn.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/);
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
const [, scheme, key, host, projectId] = m;
|
|
154
|
+
if (!scheme || !host || !key || !projectId) return null;
|
|
155
|
+
return { scheme, host, key, projectId };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── envelope builder (pure; byte-compatible with dev_metrics.py _build_envelope)
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the Sentry envelope text for one record. `record` is the shipped object
|
|
162
|
+
* (JSONL object minus `ts`), `epoch` is integer seconds, `id` is a 32-char hex.
|
|
163
|
+
* Exported so the byte-compat test can replay a golden record's epoch/id.
|
|
164
|
+
*/
|
|
165
|
+
export function buildEnvelope(
|
|
166
|
+
record: Record<string, string>,
|
|
167
|
+
epoch: number,
|
|
168
|
+
id: string,
|
|
169
|
+
): string {
|
|
170
|
+
if (record.tier === "event") {
|
|
171
|
+
const body = {
|
|
172
|
+
event_id: id,
|
|
173
|
+
timestamp: epoch,
|
|
174
|
+
platform: "other",
|
|
175
|
+
logger: "dev-tooling",
|
|
176
|
+
level: "warning",
|
|
177
|
+
message: record.event,
|
|
178
|
+
environment: ENVIRONMENT,
|
|
179
|
+
tags: { ...record },
|
|
180
|
+
};
|
|
181
|
+
const payload = JSON.stringify(body);
|
|
182
|
+
const itemHeader = JSON.stringify({
|
|
183
|
+
type: "event",
|
|
184
|
+
content_type: "application/json",
|
|
185
|
+
length: Buffer.byteLength(payload, "utf8"),
|
|
186
|
+
});
|
|
187
|
+
const envHeader = JSON.stringify({ event_id: id });
|
|
188
|
+
return `${envHeader}\n${itemHeader}\n${payload}\n`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const attributes: Record<string, { value: string | number; type: string }> =
|
|
192
|
+
{};
|
|
193
|
+
for (const [k, v] of Object.entries(record)) {
|
|
194
|
+
attributes[k] = { value: v, type: "string" };
|
|
195
|
+
}
|
|
196
|
+
attributes["sentry.severity_number"] = { value: 9, type: "integer" };
|
|
197
|
+
attributes["sentry.severity_text"] = { value: "info", type: "string" };
|
|
198
|
+
const logRecord = {
|
|
199
|
+
timestamp: epoch,
|
|
200
|
+
trace_id: id,
|
|
201
|
+
level: "info",
|
|
202
|
+
body: record.event,
|
|
203
|
+
attributes,
|
|
204
|
+
};
|
|
205
|
+
const payload = JSON.stringify({ version: 2, items: [logRecord] });
|
|
206
|
+
const itemHeader = JSON.stringify({
|
|
207
|
+
type: "log",
|
|
208
|
+
content_type: "application/vnd.sentry.items.log+json",
|
|
209
|
+
item_count: 1,
|
|
210
|
+
length: Buffer.byteLength(payload, "utf8"),
|
|
211
|
+
});
|
|
212
|
+
return `{}\n${itemHeader}\n${payload}\n`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function ship(
|
|
216
|
+
record: Record<string, string>,
|
|
217
|
+
epoch: number,
|
|
218
|
+
root: string,
|
|
219
|
+
): void {
|
|
220
|
+
const dsn = resolveDsn(root);
|
|
221
|
+
if (!dsn) return;
|
|
222
|
+
const parsed = parseDsn(dsn);
|
|
223
|
+
if (!parsed) return;
|
|
224
|
+
const envelope = buildEnvelope(record, epoch, randomUUID().replace(/-/g, ""));
|
|
225
|
+
|
|
226
|
+
// Test/inspection seam — capture the envelope instead of shipping.
|
|
227
|
+
const capture = process.env.DEV_METRICS_ENVELOPE_OUT;
|
|
228
|
+
if (capture) {
|
|
229
|
+
try {
|
|
230
|
+
fs.appendFileSync(capture, envelope);
|
|
231
|
+
} catch {
|
|
232
|
+
/* fail-open */
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const url = `${parsed.scheme}://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
|
238
|
+
const auth = `Sentry sentry_key=${parsed.key}, sentry_version=7, sentry_client=dev-metrics-${product().cli}/1.0`;
|
|
239
|
+
// Fire-and-forget: never awaited, so the caller never blocks. Bounded by a 2s
|
|
240
|
+
// abort so a hung ingest can't leak a pending request in the long-lived server.
|
|
241
|
+
void fetch(url, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: {
|
|
244
|
+
"Content-Type": "application/x-sentry-envelope",
|
|
245
|
+
"X-Sentry-Auth": auth,
|
|
246
|
+
},
|
|
247
|
+
body: envelope,
|
|
248
|
+
signal: AbortSignal.timeout(SHIP_TIMEOUT_MS),
|
|
249
|
+
})
|
|
250
|
+
// Discard the body. The status is never read, and an unconsumed body keeps
|
|
251
|
+
// the connection checked out of undici's pool in this long-lived server —
|
|
252
|
+
// one leak per emitted event. Same reason health.ts cancels its bodies.
|
|
253
|
+
.then((res) => res.body?.cancel())
|
|
254
|
+
.catch(() => {
|
|
255
|
+
/* fail-open: offline / bad DSN / timeout → drop, JSONL already buffered */
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── public API ────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Record one telemetry event. Never throws, never blocks. Extra fields are
|
|
263
|
+
* stringified (matching dev_metrics.py); pass only PII-safe values — registered
|
|
264
|
+
* command names, flag names (not values), error classes (not messages).
|
|
265
|
+
*/
|
|
266
|
+
export function record(
|
|
267
|
+
event: string,
|
|
268
|
+
fields: Record<string, string | number | boolean | null | undefined> = {},
|
|
269
|
+
): void {
|
|
270
|
+
try {
|
|
271
|
+
if (!event) return;
|
|
272
|
+
const root = repoRoot();
|
|
273
|
+
const epoch = Math.floor(Date.now() / 1000);
|
|
274
|
+
|
|
275
|
+
const obj: Record<string, string> = { ts: isoNoMillis() };
|
|
276
|
+
Object.assign(obj, commonFields(event, root));
|
|
277
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
278
|
+
obj[k] = v == null ? "" : String(v);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const dir = path.join(root, ".dev-metrics");
|
|
283
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
284
|
+
fs.appendFileSync(
|
|
285
|
+
path.join(dir, "events.jsonl"),
|
|
286
|
+
JSON.stringify(obj) + "\n",
|
|
287
|
+
);
|
|
288
|
+
} catch {
|
|
289
|
+
/* non-writable dir → fail-open */
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (suppressShip()) return;
|
|
293
|
+
// The JSONL object doubles as the Sentry attribute/tag set, minus `ts`.
|
|
294
|
+
const { ts: _ts, ...shipObj } = obj;
|
|
295
|
+
void _ts;
|
|
296
|
+
ship(shipObj, epoch, root);
|
|
297
|
+
} catch {
|
|
298
|
+
/* Fail-open: telemetry must never break a caller. */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Den's fixed tool id — event-name namespace segment AND `tool` attribute. */
|
|
303
|
+
export const TOOL = `${product().cli}`;
|
|
304
|
+
|
|
305
|
+
export type Surface = "cli" | "ux";
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Record one `devtool.den.invocation` (Tier-1). One per user-initiated run.
|
|
309
|
+
* `command` MUST be a registered command/action name (never raw argv).
|
|
310
|
+
*/
|
|
311
|
+
export function recordInvocation(opts: {
|
|
312
|
+
command: string;
|
|
313
|
+
surface: Surface;
|
|
314
|
+
durationMs?: number;
|
|
315
|
+
exit?: number;
|
|
316
|
+
extra?: Record<string, string | number | boolean | null | undefined>;
|
|
317
|
+
}): void {
|
|
318
|
+
record(`devtool.${product().name}.invocation`, {
|
|
319
|
+
tool: TOOL,
|
|
320
|
+
command: opts.command,
|
|
321
|
+
surface: opts.surface,
|
|
322
|
+
duration_ms: opts.durationMs,
|
|
323
|
+
exit: opts.exit,
|
|
324
|
+
...opts.extra,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Record one `devtool.den.failed`. Ships Tier-1 today (add to common-iac's
|
|
330
|
+
* TIER2 allowlist to make it alertable). `reason` MUST be an error *class*, not
|
|
331
|
+
* a message.
|
|
332
|
+
*/
|
|
333
|
+
export function recordFailure(opts: {
|
|
334
|
+
command: string;
|
|
335
|
+
surface: Surface;
|
|
336
|
+
reason: string;
|
|
337
|
+
durationMs?: number;
|
|
338
|
+
extra?: Record<string, string | number | boolean | null | undefined>;
|
|
339
|
+
}): void {
|
|
340
|
+
record(`devtool.${product().name}.failed`, {
|
|
341
|
+
tool: TOOL,
|
|
342
|
+
command: opts.command,
|
|
343
|
+
surface: opts.surface,
|
|
344
|
+
reason: opts.reason,
|
|
345
|
+
duration_ms: opts.durationMs,
|
|
346
|
+
...opts.extra,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Start a timer; returns a fn giving elapsed whole milliseconds. */
|
|
351
|
+
export function startTimer(): () => number {
|
|
352
|
+
const t0 = Date.now();
|
|
353
|
+
return () => Date.now() - t0;
|
|
354
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads `edge:` (and `web:`) from config/den.yml, with per-engineer
|
|
3
|
+
* overrides from config/local/user.yml.
|
|
4
|
+
*
|
|
5
|
+
* edge:
|
|
6
|
+
* org: acme
|
|
7
|
+
* domain: acme.localhost
|
|
8
|
+
* aliases: [acme.internal]
|
|
9
|
+
* image: traefik:v3.7.17
|
|
10
|
+
* workspaces: [main]
|
|
11
|
+
* routes: { <name>: <upstream url> }
|
|
12
|
+
* publish: true # false: another proxy fronts the edge
|
|
13
|
+
* http_port: 80
|
|
14
|
+
* https_port: 443
|
|
15
|
+
* # opt-ins (all off by default):
|
|
16
|
+
* caroot: ~/path/to/mkcert-ca # use an existing CA; never mint one
|
|
17
|
+
* issue_cert: false # included config holds the certs
|
|
18
|
+
* https_redirect: false # included config redirects per name
|
|
19
|
+
* docker_labels: { network: apps-network }
|
|
20
|
+
* include: edge/dynamic # dynamic-config files, repo-relative
|
|
21
|
+
* mounts: { <host path>: <container path> } # read-only
|
|
22
|
+
* networks: [<external network>]
|
|
23
|
+
* args: [--accesslog=true]
|
|
24
|
+
* web:
|
|
25
|
+
* port: 7777 # the web UI; routed as `<brand name>`
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "fs";
|
|
28
|
+
import * as os from "os";
|
|
29
|
+
import * as path from "path";
|
|
30
|
+
import * as yaml from "yaml";
|
|
31
|
+
import { brandSlug, defaultBrand } from "../brand.js";
|
|
32
|
+
import { DEFAULT_PORT } from "../constants.js";
|
|
33
|
+
import { getDistRoot } from "../paths.js";
|
|
34
|
+
import { readManifestStacks } from "../stacks/config.js";
|
|
35
|
+
import { getActiveWorkspaces } from "../environment-manager/workspaces.js";
|
|
36
|
+
import { assertDomain } from "./render.js";
|
|
37
|
+
import type { EdgeConfig } from "./types.js";
|
|
38
|
+
|
|
39
|
+
import { product } from "../product.js";
|
|
40
|
+
export const DEFAULT_TRAEFIK_IMAGE = "traefik:v3.7.17";
|
|
41
|
+
|
|
42
|
+
interface RawEdge {
|
|
43
|
+
org?: string;
|
|
44
|
+
domain?: string;
|
|
45
|
+
aliases?: string[];
|
|
46
|
+
image?: string;
|
|
47
|
+
workspaces?: string[];
|
|
48
|
+
routes?: Record<string, string>;
|
|
49
|
+
state_dir?: string;
|
|
50
|
+
publish?: boolean;
|
|
51
|
+
http_port?: number;
|
|
52
|
+
https_port?: number;
|
|
53
|
+
caroot?: string;
|
|
54
|
+
issue_cert?: boolean;
|
|
55
|
+
https_redirect?: boolean;
|
|
56
|
+
docker_labels?: { network?: string } | boolean;
|
|
57
|
+
include?: string;
|
|
58
|
+
mounts?: Record<string, string>;
|
|
59
|
+
networks?: string[];
|
|
60
|
+
args?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readYaml(file: string): Record<string, unknown> {
|
|
64
|
+
try {
|
|
65
|
+
return (
|
|
66
|
+
(yaml.parse(fs.readFileSync(file, "utf-8")) as Record<string, unknown>) ??
|
|
67
|
+
{}
|
|
68
|
+
);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function layers(root: string) {
|
|
76
|
+
return {
|
|
77
|
+
team: readYaml(path.join(root, product().configFile)),
|
|
78
|
+
user: readYaml(path.join(root, "config", "local", "user.yml")),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function expandHome(p: string): string {
|
|
83
|
+
return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `~/x` → home; a relative path → under the repo root. */
|
|
87
|
+
function resolvePath(p: string, root: string): string {
|
|
88
|
+
const e = expandHome(p);
|
|
89
|
+
return path.isAbsolute(e) ? e : path.join(root, e);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function list(v: unknown, name: string): string[] {
|
|
93
|
+
if (v === undefined) return [];
|
|
94
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string"))
|
|
95
|
+
throw new Error(`edge.${name} must be a list of strings`);
|
|
96
|
+
return v as string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function dockerLabels(v: RawEdge["docker_labels"]): { network: string } | null {
|
|
100
|
+
if (v === undefined || v === false) return null;
|
|
101
|
+
const network = typeof v === "object" ? v.network : undefined;
|
|
102
|
+
if (!network || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(network))
|
|
103
|
+
throw new Error(
|
|
104
|
+
"edge.docker_labels needs docker_labels.network: the Docker network " +
|
|
105
|
+
"the edge reaches labelled containers on, e.g. { network: apps-network }",
|
|
106
|
+
);
|
|
107
|
+
return { network };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function mounts(v: RawEdge["mounts"], root: string): Record<string, string> {
|
|
111
|
+
const out: Record<string, string> = {};
|
|
112
|
+
for (const [host, target] of Object.entries(v ?? {})) {
|
|
113
|
+
if (typeof target !== "string" || !path.posix.isAbsolute(target))
|
|
114
|
+
throw new Error(
|
|
115
|
+
`edge.mounts: "${host}" must map to an absolute container path, got "${String(target)}"`,
|
|
116
|
+
);
|
|
117
|
+
out[resolvePath(host, root)] = target;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The web UI's route name: the brand name as a slug ("Den" → "den"). */
|
|
123
|
+
function webRouteName(team: Record<string, unknown>): string {
|
|
124
|
+
const name = (team.brand as { name?: string } | undefined)?.name?.trim();
|
|
125
|
+
return brandSlug({ ...defaultBrand(), name: name || defaultBrand().name });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function port(v: unknown, name: string): number {
|
|
129
|
+
const n = Number(v);
|
|
130
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
131
|
+
throw new Error(`${name} must be a port number, got "${String(v)}"`);
|
|
132
|
+
return n;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The den web UI port: user `web.port` > team `web.port` > 7777. */
|
|
136
|
+
export function readWebPort(root: string = getDistRoot()): number {
|
|
137
|
+
const { team, user } = layers(root);
|
|
138
|
+
const v =
|
|
139
|
+
(user.web as { port?: number } | undefined)?.port ??
|
|
140
|
+
(team.web as { port?: number } | undefined)?.port ??
|
|
141
|
+
DEFAULT_PORT;
|
|
142
|
+
return port(v, "web.port");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Stacks declared in repo manifests across the active workspaces. */
|
|
146
|
+
function manifestStackNames(): string[] {
|
|
147
|
+
try {
|
|
148
|
+
return getActiveWorkspaces().flatMap((ws) =>
|
|
149
|
+
Object.keys(readManifestStacks(ws)),
|
|
150
|
+
);
|
|
151
|
+
} catch {
|
|
152
|
+
return []; // a broken manifest must not stop the edge
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function readEdgeConfig(root: string = getDistRoot()): EdgeConfig {
|
|
157
|
+
const { team: t, user: u } = layers(root);
|
|
158
|
+
const team = (t.edge ?? {}) as RawEdge;
|
|
159
|
+
const user = (u.edge ?? {}) as RawEdge;
|
|
160
|
+
if (!team.org || !team.domain)
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${product().configFile} has no \`edge:\` block with \`org\` and \`domain\` — ` +
|
|
163
|
+
"add one to give this instance its own HTTPS edge.",
|
|
164
|
+
);
|
|
165
|
+
if (!/^[a-z0-9-]+$/.test(team.org))
|
|
166
|
+
throw new Error(
|
|
167
|
+
`edge.org "${team.org}" must be lowercase letters, digits and dashes`,
|
|
168
|
+
);
|
|
169
|
+
assertDomain(team.domain);
|
|
170
|
+
const aliases = user.aliases ?? team.aliases ?? [];
|
|
171
|
+
if (!Array.isArray(aliases))
|
|
172
|
+
throw new Error("edge.aliases must be a list of domains");
|
|
173
|
+
aliases.forEach(assertDomain);
|
|
174
|
+
const publish = user.publish ?? team.publish ?? true;
|
|
175
|
+
return {
|
|
176
|
+
org: team.org,
|
|
177
|
+
domain: team.domain,
|
|
178
|
+
aliases,
|
|
179
|
+
image: user.image ?? team.image ?? DEFAULT_TRAEFIK_IMAGE,
|
|
180
|
+
workspaces: user.workspaces ?? team.workspaces ?? ["main"],
|
|
181
|
+
subzones: [
|
|
182
|
+
...new Set([
|
|
183
|
+
...Object.keys((t.stacks as Record<string, unknown>) ?? {}),
|
|
184
|
+
...manifestStackNames(),
|
|
185
|
+
]),
|
|
186
|
+
].sort(),
|
|
187
|
+
routes: {
|
|
188
|
+
[webRouteName(t)]: `http://host.docker.internal:${readWebPort(root)}`,
|
|
189
|
+
...(team.routes ?? {}),
|
|
190
|
+
...(user.routes ?? {}),
|
|
191
|
+
},
|
|
192
|
+
ports: publish
|
|
193
|
+
? {
|
|
194
|
+
http: port(user.http_port ?? team.http_port ?? 80, "edge.http_port"),
|
|
195
|
+
https: port(
|
|
196
|
+
user.https_port ?? team.https_port ?? 443,
|
|
197
|
+
"edge.https_port",
|
|
198
|
+
),
|
|
199
|
+
}
|
|
200
|
+
: null,
|
|
201
|
+
stateDir: expandHome(
|
|
202
|
+
process.env.DEN_EDGE_DIR ??
|
|
203
|
+
user.state_dir ??
|
|
204
|
+
team.state_dir ??
|
|
205
|
+
path.join(
|
|
206
|
+
process.env.XDG_DATA_HOME ||
|
|
207
|
+
path.join(os.homedir(), ".local", "share"),
|
|
208
|
+
team.org,
|
|
209
|
+
"edge",
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
caroot:
|
|
213
|
+
(user.caroot ?? team.caroot)
|
|
214
|
+
? resolvePath((user.caroot ?? team.caroot)!, root)
|
|
215
|
+
: null,
|
|
216
|
+
issueCert: (user.issue_cert ?? team.issue_cert) !== false,
|
|
217
|
+
httpsRedirect: (user.https_redirect ?? team.https_redirect) !== false,
|
|
218
|
+
dockerLabels: dockerLabels(user.docker_labels ?? team.docker_labels),
|
|
219
|
+
include: team.include ? resolvePath(team.include, root) : null,
|
|
220
|
+
mounts: mounts({ ...(team.mounts ?? {}), ...(user.mounts ?? {}) }, root),
|
|
221
|
+
networks: [
|
|
222
|
+
...new Set([
|
|
223
|
+
...list(team.networks, "networks"),
|
|
224
|
+
...list(user.networks, "networks"),
|
|
225
|
+
]),
|
|
226
|
+
],
|
|
227
|
+
args: [...list(team.args, "args"), ...list(user.args, "args")],
|
|
228
|
+
};
|
|
229
|
+
}
|