@homeflare/alchemy 0.38.0 → 0.40.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 +32 -8
- package/dist/ceph/auth-entity.d.ts +34 -0
- package/dist/ceph/auth-entity.d.ts.map +1 -0
- package/dist/ceph/ceph-argv-config.d.ts +10 -0
- package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
- package/dist/ceph/ceph-argv.d.ts +19 -0
- package/dist/ceph/ceph-argv.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-form.d.ts +54 -0
- package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-parse.d.ts +56 -0
- package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
- package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
- package/dist/ceph/ceph-config-options.d.ts +23 -0
- package/dist/ceph/ceph-config-options.d.ts.map +1 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
- package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
- package/dist/ceph/ceph-transport.d.ts +37 -0
- package/dist/ceph/ceph-transport.d.ts.map +1 -0
- package/dist/ceph/fake-ceph-dial.d.ts +35 -0
- package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
- package/dist/ceph/index.d.ts +13 -0
- package/dist/ceph/index.d.ts.map +1 -0
- package/dist/ceph/index.js +1609 -0
- package/dist/ceph/index.js.map +38 -0
- package/dist/forgejo/index.js +4 -2
- package/dist/forgejo/index.js.map +3 -3
- package/dist/forgejo/team-member.d.ts +25 -0
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/index.d.ts +14 -3
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +574 -133
- package/dist/proxmox/index.js.map +33 -23
- package/dist/proxmox/qemu-form.d.ts +27 -18
- package/dist/proxmox/qemu-form.d.ts.map +1 -1
- package/dist/proxmox/qemu-judge.d.ts +30 -0
- package/dist/proxmox/qemu-judge.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu-net.d.ts +9 -0
- package/dist/proxmox/qemu-net.d.ts.map +1 -0
- package/dist/proxmox/qemu-props.d.ts +112 -0
- package/dist/proxmox/qemu-props.d.ts.map +1 -0
- package/dist/proxmox/qemu-volume.d.ts +27 -0
- package/dist/proxmox/qemu-volume.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +14 -27
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/storage-download-errors.d.ts +5 -0
- package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
- package/dist/proxmox/storage-download-identity.d.ts +27 -0
- package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/storage-download-props.d.ts +36 -0
- package/dist/proxmox/storage-download-props.d.ts.map +1 -0
- package/dist/proxmox/storage-download-task.d.ts +17 -0
- package/dist/proxmox/storage-download-task.d.ts.map +1 -0
- package/dist/proxmox/storage-download.d.ts +44 -0
- package/dist/proxmox/storage-download.d.ts.map +1 -0
- package/dist/talos/credentials-write.d.ts +46 -0
- package/dist/talos/credentials-write.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +51 -9
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/fake-process.d.ts +56 -0
- package/dist/talos/fake-process.d.ts.map +1 -0
- package/dist/talos/index.d.ts +19 -4
- package/dist/talos/index.d.ts.map +1 -1
- package/dist/talos/index.js +357 -173
- package/dist/talos/index.js.map +16 -10
- package/dist/talos/kubeconfig-attrs.d.ts +4 -0
- package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
- package/dist/talos/kubeconfig.d.ts +41 -23
- package/dist/talos/kubeconfig.d.ts.map +1 -1
- package/dist/talos/machine-config-fixtures.d.ts +60 -0
- package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
- package/dist/talos/machine-config-poll.d.ts +43 -0
- package/dist/talos/machine-config-poll.d.ts.map +1 -0
- package/dist/talos/machine-config-read.d.ts +49 -0
- package/dist/talos/machine-config-read.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +18 -0
- package/dist/talos/resource.d.ts.map +1 -1
- package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
- package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +87 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -1
- package/dist/talos/talos-cluster-health.d.ts +43 -1
- package/dist/talos/talos-cluster-health.d.ts.map +1 -1
- package/dist/talos/talos-errors.d.ts +68 -0
- package/dist/talos/talos-errors.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +51 -15
- package/dist/talos/talos-machine-config.d.ts.map +1 -1
- package/dist/talos/talosctl.d.ts +7 -0
- package/dist/talos/talosctl.d.ts.map +1 -1
- package/dist/talos/values.d.ts +23 -3
- package/dist/talos/values.d.ts.map +1 -1
- package/dist/telemetry/fake-collector.d.ts +36 -0
- package/dist/telemetry/fake-collector.d.ts.map +1 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +223 -0
- package/dist/telemetry/index.js.map +12 -0
- package/dist/telemetry/layer.d.ts +86 -0
- package/dist/telemetry/layer.d.ts.map +1 -0
- package/dist/telemetry/redact-serialization.d.ts +21 -0
- package/dist/telemetry/redact-serialization.d.ts.map +1 -0
- package/dist/telemetry/redact.d.ts +46 -0
- package/dist/telemetry/redact.d.ts.map +1 -0
- package/dist/unifi/acl-rule-drift.d.ts +22 -0
- package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
- package/dist/unifi/acl-rule-form.d.ts +90 -0
- package/dist/unifi/acl-rule-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
- package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
- package/dist/unifi/acl-rule-ordering.d.ts +54 -0
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
- package/dist/unifi/acl-rule.d.ts +49 -0
- package/dist/unifi/acl-rule.d.ts.map +1 -0
- package/dist/unifi/dns-policy-drift.d.ts +21 -0
- package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
- package/dist/unifi/dns-policy-form.d.ts +97 -0
- package/dist/unifi/dns-policy-form.d.ts.map +1 -0
- package/dist/unifi/dns-policy.d.ts +55 -0
- package/dist/unifi/dns-policy.d.ts.map +1 -0
- package/dist/unifi/drift.d.ts +27 -0
- package/dist/unifi/drift.d.ts.map +1 -0
- package/dist/unifi/fake-unifi.d.ts +5 -1
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-drift.d.ts +26 -0
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-form.d.ts +102 -0
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
- package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
- package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
- package/dist/unifi/firewall-policy.d.ts +57 -0
- package/dist/unifi/firewall-policy.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +6 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +11 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +725 -60
- package/dist/unifi/index.js.map +31 -9
- package/dist/unifi/network-drift.d.ts +26 -0
- package/dist/unifi/network-drift.d.ts.map +1 -0
- package/dist/unifi/network-form.d.ts +40 -7
- package/dist/unifi/network-form.d.ts.map +1 -1
- package/dist/unifi/network.d.ts +1 -0
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/paginate.d.ts +24 -0
- package/dist/unifi/paginate.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +1 -1
- package/dist/unifi/providers.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +36 -1
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/scan-source.d.ts +11 -0
- package/dist/unifi/scan-source.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
- package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
- package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
- package/dist/unifi/traffic-matching-list.d.ts +51 -0
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
- package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
- package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +73 -0
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
- package/docs/telemetry-spike.md +94 -0
- package/docs/telemetry.md +109 -0
- package/docs/unifi-acl-rule.md +122 -0
- package/docs/unifi-dns-policy.md +75 -0
- package/docs/unifi-firewall-policy.md +109 -0
- package/docs/unifi-traffic-matching-list.md +82 -0
- package/docs/unifi-wifi.md +120 -0
- package/docs/unifi.md +64 -3
- package/package.json +10 -2
- package/src/ceph/auth-entity.ts +74 -0
- package/src/ceph/ceph-argv-config.ts +78 -0
- package/src/ceph/ceph-argv.test.ts +226 -0
- package/src/ceph/ceph-argv.ts +189 -0
- package/src/ceph/ceph-auth-form.test.ts +103 -0
- package/src/ceph/ceph-auth-form.ts +107 -0
- package/src/ceph/ceph-auth-parse.test.ts +155 -0
- package/src/ceph/ceph-auth-parse.ts +121 -0
- package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
- package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
- package/src/ceph/ceph-auth-reconcile.ts +119 -0
- package/src/ceph/ceph-config-options.test.ts +44 -0
- package/src/ceph/ceph-config-options.ts +36 -0
- package/src/ceph/ceph-key-leak-assert.ts +35 -0
- package/src/ceph/ceph-no-output-logging.test.ts +52 -0
- package/src/ceph/ceph-transport.test.ts +125 -0
- package/src/ceph/ceph-transport.ts +117 -0
- package/src/ceph/fake-ceph-dial.ts +55 -0
- package/src/ceph/index.ts +12 -0
- package/src/forgejo/team-member.test.ts +14 -0
- package/src/forgejo/team-member.ts +28 -1
- package/src/index.ts +1 -0
- package/src/proxmox/constraints-required.test.ts +1 -0
- package/src/proxmox/generated/constraints/index.ts +3 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/index.test.ts +11 -6
- package/src/proxmox/index.ts +19 -3
- package/src/proxmox/qemu-form.ts +49 -13
- package/src/proxmox/qemu-identity.test.ts +30 -0
- package/src/proxmox/qemu-judge.test.ts +65 -0
- package/src/proxmox/qemu-judge.ts +84 -0
- package/src/proxmox/qemu-lifecycle.test.ts +78 -19
- package/src/proxmox/qemu-lifecycle.ts +131 -32
- package/src/proxmox/qemu-net.test.ts +48 -0
- package/src/proxmox/qemu-net.ts +78 -0
- package/src/proxmox/qemu-ownership.test.ts +44 -0
- package/src/proxmox/qemu-props.test.ts +87 -0
- package/src/proxmox/qemu-props.ts +157 -0
- package/src/proxmox/qemu-volume.test.ts +229 -0
- package/src/proxmox/qemu-volume.ts +178 -0
- package/src/proxmox/qemu.ts +15 -29
- package/src/proxmox/storage-download-errors.ts +7 -0
- package/src/proxmox/storage-download-identity.test.ts +54 -0
- package/src/proxmox/storage-download-identity.ts +50 -0
- package/src/proxmox/storage-download-lifecycle.ts +226 -0
- package/src/proxmox/storage-download-ownership.test.ts +50 -0
- package/src/proxmox/storage-download-props.ts +38 -0
- package/src/proxmox/storage-download-task.ts +72 -0
- package/src/proxmox/storage-download.test.ts +216 -0
- package/src/proxmox/storage-download.ts +58 -0
- package/src/talos/credentials-write.test.ts +81 -0
- package/src/talos/credentials-write.ts +95 -0
- package/src/talos/credentials.test.ts +157 -0
- package/src/talos/credentials.ts +160 -90
- package/src/talos/fake-process.ts +94 -0
- package/src/talos/index.ts +39 -4
- package/src/talos/kubeconfig-attrs.ts +55 -0
- package/src/talos/kubeconfig.test.ts +256 -0
- package/src/talos/kubeconfig.ts +170 -90
- package/src/talos/machine-config-fixtures.ts +86 -0
- package/src/talos/machine-config-poll.test.ts +57 -0
- package/src/talos/machine-config-poll.ts +82 -0
- package/src/talos/machine-config-read.ts +118 -0
- package/src/talos/resource.ts +19 -0
- package/src/talos/secrets-gate.test.ts +63 -0
- package/src/talos/talos-bootstrap-etcd.ts +71 -0
- package/src/talos/talos-bootstrap.test.ts +218 -0
- package/src/talos/talos-bootstrap.ts +151 -45
- package/src/talos/talos-cluster-health.test.ts +143 -0
- package/src/talos/talos-cluster-health.ts +91 -28
- package/src/talos/talos-errors.ts +85 -0
- package/src/talos/talos-machine-config.test.ts +189 -0
- package/src/talos/talos-machine-config.ts +102 -88
- package/src/talos/talosctl.ts +8 -0
- package/src/talos/values.test.ts +34 -6
- package/src/talos/values.ts +41 -7
- package/src/telemetry/fake-collector.ts +59 -0
- package/src/telemetry/http-client-sealing.test.ts +82 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/layer.test.ts +204 -0
- package/src/telemetry/layer.ts +152 -0
- package/src/telemetry/redact-serialization.ts +178 -0
- package/src/telemetry/redact.test.ts +124 -0
- package/src/telemetry/redact.ts +147 -0
- package/src/unifi/acl-rule-drift.ts +57 -0
- package/src/unifi/acl-rule-form.ts +135 -0
- package/src/unifi/acl-rule-ordering-form.ts +79 -0
- package/src/unifi/acl-rule-ordering.test.ts +141 -0
- package/src/unifi/acl-rule-ordering.ts +70 -0
- package/src/unifi/acl-rule.test.ts +183 -0
- package/src/unifi/acl-rule.ts +61 -0
- package/src/unifi/discriminated-filter-decode.test.ts +134 -0
- package/src/unifi/dns-policy-drift.ts +48 -0
- package/src/unifi/dns-policy-form.ts +142 -0
- package/src/unifi/dns-policy.test.ts +154 -0
- package/src/unifi/dns-policy.ts +67 -0
- package/src/unifi/drift.test.ts +78 -0
- package/src/unifi/drift.ts +56 -0
- package/src/unifi/errors-and-secrets.test.ts +127 -0
- package/src/unifi/fake-unifi.ts +5 -1
- package/src/unifi/firewall-policy-drift.test.ts +151 -0
- package/src/unifi/firewall-policy-drift.ts +55 -0
- package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
- package/src/unifi/firewall-policy-form.ts +146 -0
- package/src/unifi/firewall-policy-ordering-form.ts +111 -0
- package/src/unifi/firewall-policy-ordering.test.ts +185 -0
- package/src/unifi/firewall-policy-ordering.ts +87 -0
- package/src/unifi/firewall-policy.test.ts +183 -0
- package/src/unifi/firewall-policy.ts +73 -0
- package/src/unifi/firewall-zone-form.ts +31 -2
- package/src/unifi/firewall-zone.test.ts +21 -1
- package/src/unifi/firewall-zone.ts +1 -1
- package/src/unifi/get-only-guard.test.ts +165 -0
- package/src/unifi/index.ts +78 -1
- package/src/unifi/network-drift.test.ts +207 -0
- package/src/unifi/network-drift.ts +80 -0
- package/src/unifi/network-form.ts +11 -39
- package/src/unifi/network.test.ts +7 -82
- package/src/unifi/network.ts +6 -6
- package/src/unifi/paginate.test.ts +252 -0
- package/src/unifi/paginate.ts +141 -0
- package/src/unifi/providers.ts +19 -1
- package/src/unifi/resource.ts +73 -3
- package/src/unifi/scan-source.ts +64 -0
- package/src/unifi/traffic-matching-list-drift.ts +40 -0
- package/src/unifi/traffic-matching-list-form.ts +77 -0
- package/src/unifi/traffic-matching-list.test.ts +202 -0
- package/src/unifi/traffic-matching-list.ts +78 -0
- package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
- package/src/unifi/wifi-broadcast-drift.ts +69 -0
- package/src/unifi/wifi-broadcast-form.ts +211 -0
- package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
- package/src/unifi/wifi-broadcast.test.ts +155 -0
- package/src/unifi/wifi-broadcast.ts +90 -0
- package/src/unifi/write-op-reference.test.ts +190 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The leak `layer.ts`'s own ★ describes: caddy's `admin-transport-scope.test.ts` measured that a
|
|
3
|
+
* provider Layer merging its own `HttpClient.HttpClient` straight into its output can shadow a
|
|
4
|
+
* sibling fetch-based provider's ambient client once both sit in the same `Layer.mergeAll`. This
|
|
5
|
+
* file is that same test shape, for `telemetryLayer`'s own (real, `fetch`-backed) `FetchHttpClient`.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
8
|
+
import { Resource } from 'alchemy';
|
|
9
|
+
import * as Provider from 'alchemy/Provider';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
13
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
14
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
15
|
+
import { type FakeCollector, fakeCollector } from './fake-collector.ts';
|
|
16
|
+
import { telemetryLayer } from './layer.ts';
|
|
17
|
+
|
|
18
|
+
interface Probe extends Resource<'Test.TelemetrySealingProbe', { name: string }, { via: string }> {}
|
|
19
|
+
const Probe = Resource<Probe>('Test.TelemetrySealingProbe');
|
|
20
|
+
|
|
21
|
+
/** A sibling fetch-based provider (Forgejo, LiteLLM in the estate) — it just needs SOME
|
|
22
|
+
* `HttpClient.HttpClient` in scope, and reports which one it got. */
|
|
23
|
+
const ProbeProvider = (vias: string[]) => {
|
|
24
|
+
const hit = Effect.gen(function* () {
|
|
25
|
+
const client = yield* HttpClient.HttpClient;
|
|
26
|
+
const response = yield* client.get('http://sibling.invalid/probe');
|
|
27
|
+
vias.push(yield* response.text);
|
|
28
|
+
});
|
|
29
|
+
return Provider.succeed(Probe, {
|
|
30
|
+
read: () => Effect.as(hit, undefined),
|
|
31
|
+
reconcile: () => Effect.as(hit, { via: 'ambient' }),
|
|
32
|
+
delete: () => Effect.void,
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Answers instantly and records that IT (not telemetry's own client) was the one asked. */
|
|
37
|
+
const labeledClient = (label: string, seen: string[]): Layer.Layer<HttpClient.HttpClient> =>
|
|
38
|
+
Layer.succeed(
|
|
39
|
+
HttpClient.HttpClient,
|
|
40
|
+
HttpClient.make((request) => {
|
|
41
|
+
seen.push(label);
|
|
42
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(label)));
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
let otlp: FakeCollector | undefined;
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
otlp?.stop();
|
|
49
|
+
otlp = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('telemetryLayer HttpClient sealing', () => {
|
|
53
|
+
test("a sibling provider merged alongside it gets the AMBIENT client, never telemetry's own", async () => {
|
|
54
|
+
otlp = fakeCollector();
|
|
55
|
+
const vias: string[] = [];
|
|
56
|
+
const ambient = labeledClient('ambient', vias);
|
|
57
|
+
const engine = engineOver(
|
|
58
|
+
Layer.mergeAll(
|
|
59
|
+
ProbeProvider(vias),
|
|
60
|
+
telemetryLayer({ endpoints: { traces: `${otlp.url}/v1/traces` }, serviceName: 'test' }),
|
|
61
|
+
).pipe(Layer.provideMerge(ambient)),
|
|
62
|
+
);
|
|
63
|
+
await engine.deploy(Probe('p', { name: 'sibling' }));
|
|
64
|
+
expect(vias.length).toBeGreaterThan(0);
|
|
65
|
+
expect(new Set(vias)).toEqual(new Set(['ambient']));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("with no ambient client at all, the sibling fails rather than silently reaching telemetry's own", async () => {
|
|
69
|
+
otlp = fakeCollector();
|
|
70
|
+
const vias: string[] = [];
|
|
71
|
+
// ★ No `ambient` layer this time. If `telemetryLayer`'s `FetchHttpClient` ever leaked into its
|
|
72
|
+
// own output, the sibling below would resolve it and this would succeed instead of throwing —
|
|
73
|
+
// exactly the failure mode docs/telemetry-spike.md's first run hit by accident before `ambient` was added.
|
|
74
|
+
const engine = engineOver(
|
|
75
|
+
Layer.mergeAll(
|
|
76
|
+
ProbeProvider(vias),
|
|
77
|
+
telemetryLayer({ endpoints: { traces: `${otlp.url}/v1/traces` }, serviceName: 'test' }),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
await expect(engine.deploy(Probe('p', { name: 'sibling' }))).rejects.toThrow();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@homeflare/alchemy/telemetry` — an OTLP tracing/logging/metrics Layer for a stack's own
|
|
3
|
+
* `providers`, off unless a consumer gives it real endpoints. See docs/telemetry.md for how a site
|
|
4
|
+
* file wires it up and docs/telemetry-spike.md for what a stack's `providers` Layer can and cannot
|
|
5
|
+
* capture (measured against alchemy 2.0.0-beta.79's own engine).
|
|
6
|
+
*/
|
|
7
|
+
export { telemetryLayer } from './layer.ts';
|
|
8
|
+
export type { TelemetryEndpoints, TelemetryOptions } from './layer.ts';
|
|
9
|
+
export { DROP, REDACTED, redactAttribute, wrapTracer } from './redact.ts';
|
|
10
|
+
export type { RedactionPolicy } from './redact.ts';
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `telemetryLayer` end to end, over the REAL engine (`verify/fake-engine.ts`'s `engineOver` — real
|
|
3
|
+
* `Alchemy.Stack` + `Plan.make` + `apply`, an in-memory state store, no network beyond the fake
|
|
4
|
+
* OTLP collector below) — not a hand-built stand-in. docs/telemetry-spike.md is the exploratory run this distills
|
|
5
|
+
* into permanent coverage; see it for what a stack's `providers` Layer can and cannot capture.
|
|
6
|
+
*
|
|
7
|
+
* ★ A REAL `FetchHttpClient` IS MERGED IN AS `ambient`, standing in for the platform layer a real
|
|
8
|
+
* `alchemy.run.ts`'s `Stack.ts` provides underneath the merged `providers` — exactly the shape
|
|
9
|
+
* admin-transport-scope.test.ts documents for caddy. Without it the fake provider's own
|
|
10
|
+
* `HttpClient.HttpClient` requirement has nothing to resolve to, proving on its own (a thrown
|
|
11
|
+
* "Service not found") that `telemetryLayer`'s SEALED internal client never leaks out —
|
|
12
|
+
* http-client-sealing.test.ts makes that assertion explicit instead of relying on a crash.
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
15
|
+
import { Resource } from 'alchemy';
|
|
16
|
+
import * as Provider from 'alchemy/Provider';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import * as Layer from 'effect/Layer';
|
|
19
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
20
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
21
|
+
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
22
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
23
|
+
import { type FakeCollector, fakeCollector } from './fake-collector.ts';
|
|
24
|
+
import { telemetryLayer } from './layer.ts';
|
|
25
|
+
|
|
26
|
+
interface Thing extends Resource<'Test.TelemetryThing', { name: string }, { name: string }> {}
|
|
27
|
+
const Thing = Resource<Thing>('Test.TelemetryThing');
|
|
28
|
+
|
|
29
|
+
/** Calls `target` (`GET <target>/probe?token=…`, an `authorization` header set) inside its own
|
|
30
|
+
* span — a stand-in for a real provider's own `Effect.withSpan` around an SDK call. */
|
|
31
|
+
const probingProvider = (target: string) =>
|
|
32
|
+
Provider.succeed(Thing, {
|
|
33
|
+
read: () => Effect.succeed(undefined),
|
|
34
|
+
reconcile: () =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const client = yield* HttpClient.HttpClient;
|
|
37
|
+
const request = HttpClientRequest.get(`${target}/probe?token=SECRET123`).pipe(
|
|
38
|
+
HttpClientRequest.setHeader('authorization', 'Bearer sekrit'),
|
|
39
|
+
);
|
|
40
|
+
const response = yield* client.execute(request);
|
|
41
|
+
yield* response.text;
|
|
42
|
+
return { name: 'x' };
|
|
43
|
+
}).pipe(Effect.withSpan('probe.custom-span')),
|
|
44
|
+
delete: () => Effect.void,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let collectors: FakeCollector[] = [];
|
|
48
|
+
const collector = (): FakeCollector => {
|
|
49
|
+
const c = fakeCollector();
|
|
50
|
+
collectors.push(c);
|
|
51
|
+
return c;
|
|
52
|
+
};
|
|
53
|
+
/** A plain HTTP server for the redteam repros below — a real "target" a provider calls, not an OTLP
|
|
54
|
+
* collector, so it doesn't belong in `collectors`/`FakeCollector`. */
|
|
55
|
+
let servers: Array<{ stop: () => void }> = [];
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
for (const c of collectors) c.stop();
|
|
58
|
+
collectors = [];
|
|
59
|
+
for (const s of servers) s.stop();
|
|
60
|
+
servers = [];
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** A target that answers every request 404 — a provider call that fails. */
|
|
64
|
+
const notFound = (): string => {
|
|
65
|
+
const server = Bun.serve({
|
|
66
|
+
fetch: () => new Response('nope', { status: 404 }),
|
|
67
|
+
hostname: '127.0.0.1',
|
|
68
|
+
port: 0,
|
|
69
|
+
});
|
|
70
|
+
servers.push({ stop: () => void server.stop(true) });
|
|
71
|
+
return `http://127.0.0.1:${String(server.port)}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe('telemetryLayer', () => {
|
|
75
|
+
test('sends nothing to any signal when every endpoint is left unconfigured', async () => {
|
|
76
|
+
const otlp = collector();
|
|
77
|
+
const target = collector();
|
|
78
|
+
const providers = Layer.mergeAll(
|
|
79
|
+
probingProvider(target.url),
|
|
80
|
+
telemetryLayer({ endpoints: {}, serviceName: 'test' }),
|
|
81
|
+
).pipe(Layer.provideMerge(FetchHttpClient.layer));
|
|
82
|
+
await engineOver(providers).deploy(Thing('t', { name: 'x' }));
|
|
83
|
+
expect(otlp.requests.length).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("provider spans, HttpClient client spans and alchemy's own engine spans all arrive", async () => {
|
|
87
|
+
const otlp = collector();
|
|
88
|
+
const target = collector();
|
|
89
|
+
const providers = Layer.mergeAll(
|
|
90
|
+
probingProvider(target.url),
|
|
91
|
+
telemetryLayer({ endpoints: { traces: `${otlp.url}/v1/traces` }, serviceName: 'test' }),
|
|
92
|
+
).pipe(Layer.provideMerge(FetchHttpClient.layer));
|
|
93
|
+
await engineOver(providers).deploy(Thing('t', { name: 'x' }));
|
|
94
|
+
// The provider's own span, the HttpClient client span it made, and alchemy's OWN engine
|
|
95
|
+
// instrumentation (Apply.ts `instrumentLifecycle`/`apply`/`apply.resource`, Plan.ts
|
|
96
|
+
// `plan.make`) — measured together in docs/telemetry-spike.md's run against beta.79.
|
|
97
|
+
for (const name of [
|
|
98
|
+
'probe.custom-span',
|
|
99
|
+
'http.client GET',
|
|
100
|
+
'provider.create',
|
|
101
|
+
'apply',
|
|
102
|
+
'apply.resource',
|
|
103
|
+
'plan.make',
|
|
104
|
+
]) {
|
|
105
|
+
expect(otlp.sawText(name)).toBe(true);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('redteam PR 302 item 1: OTLP bodies are protobuf, not JSON', async () => {
|
|
110
|
+
const otlp = collector();
|
|
111
|
+
const target = collector();
|
|
112
|
+
const providers = Layer.mergeAll(
|
|
113
|
+
probingProvider(target.url),
|
|
114
|
+
telemetryLayer({ endpoints: { traces: `${otlp.url}/v1/traces` }, serviceName: 'test' }),
|
|
115
|
+
).pipe(Layer.provideMerge(FetchHttpClient.layer));
|
|
116
|
+
await engineOver(providers).deploy(Thing('t', { name: 'x' }));
|
|
117
|
+
const request = otlp.at('/v1/traces')[0];
|
|
118
|
+
// VictoriaLogs/Metrics answer OTLP JSON with 400 "json encoding isn't supported" — only
|
|
119
|
+
// protobuf's `content-type` ingests on all three (docs/telemetry-spike.md's follow-up measurement).
|
|
120
|
+
expect(request?.headers.get('content-type')).toBe('application/x-protobuf');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('redaction holds for a real HttpClient call: no query string, no headers, denylisted host blanked', async () => {
|
|
124
|
+
const otlp = collector();
|
|
125
|
+
const target = collector();
|
|
126
|
+
const targetHost = new URL(target.url).hostname;
|
|
127
|
+
const providers = Layer.mergeAll(
|
|
128
|
+
probingProvider(target.url),
|
|
129
|
+
telemetryLayer({
|
|
130
|
+
endpoints: { traces: `${otlp.url}/v1/traces` },
|
|
131
|
+
redaction: { denylist: [targetHost] },
|
|
132
|
+
serviceName: 'test',
|
|
133
|
+
}),
|
|
134
|
+
).pipe(Layer.provideMerge(FetchHttpClient.layer));
|
|
135
|
+
await engineOver(providers).deploy(Thing('t', { name: 'x' }));
|
|
136
|
+
expect(otlp.sawText('SECRET123')).toBe(false);
|
|
137
|
+
expect(otlp.sawText('sekrit')).toBe(false);
|
|
138
|
+
expect(otlp.sawText('http.request.header')).toBe(false);
|
|
139
|
+
expect(otlp.sawText(targetHost)).toBe(false);
|
|
140
|
+
expect(otlp.sawText('<redacted>')).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('redteam PR 302 items 2+3: a FAILED provider call no longer leaks the query secret or denylisted segments via exception/status', async () => {
|
|
144
|
+
const otlp = collector();
|
|
145
|
+
const targetUrl = notFound();
|
|
146
|
+
const host = new URL(targetUrl).hostname;
|
|
147
|
+
const secretUrl = `${targetUrl}/secret-site-abc123/probe?token=SECRET123`;
|
|
148
|
+
const failingProvider = Provider.succeed(Thing, {
|
|
149
|
+
read: () => Effect.succeed(undefined),
|
|
150
|
+
reconcile: () =>
|
|
151
|
+
Effect.gen(function* () {
|
|
152
|
+
const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
|
|
153
|
+
const response = yield* client.execute(HttpClientRequest.get(secretUrl));
|
|
154
|
+
yield* response.text;
|
|
155
|
+
return { name: 'x' };
|
|
156
|
+
}).pipe(Effect.withSpan('rt.provider-call')),
|
|
157
|
+
delete: () => Effect.void,
|
|
158
|
+
});
|
|
159
|
+
const providers = Layer.mergeAll(
|
|
160
|
+
failingProvider,
|
|
161
|
+
telemetryLayer({
|
|
162
|
+
endpoints: { traces: `${otlp.url}/v1/traces` },
|
|
163
|
+
redaction: { denylist: [host, 'secret-site-abc123'] },
|
|
164
|
+
serviceName: 'rt',
|
|
165
|
+
}),
|
|
166
|
+
).pipe(Layer.provideMerge(FetchHttpClient.layer));
|
|
167
|
+
await engineOver(providers)
|
|
168
|
+
.deploy(Thing('t', { name: 'x' }))
|
|
169
|
+
.catch(() => undefined);
|
|
170
|
+
// Redteam measured this leaking via `exception.message`/`status.message`, computed from the exit's
|
|
171
|
+
// Cause at export time — after `wrapTracer`'s own hooks already ran. `redactedSerialization`
|
|
172
|
+
// (redact-serialization.ts) is the fix: it scrubs the wire-format `TraceData` one level deeper.
|
|
173
|
+
expect(otlp.sawText('SECRET123')).toBe(false);
|
|
174
|
+
expect(otlp.sawText('secret-site-abc123')).toBe(false);
|
|
175
|
+
expect(otlp.sawText(`${host}:`)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('redteam PR 302 items 2+3: a log line inside a span no longer leaks via the span event name', async () => {
|
|
179
|
+
const otlp = collector();
|
|
180
|
+
const secretUrl = 'http://private.example.test/secret-site-abc123/x?token=SECRET123';
|
|
181
|
+
const loggingProvider = Provider.succeed(Thing, {
|
|
182
|
+
read: () => Effect.succeed(undefined),
|
|
183
|
+
reconcile: () =>
|
|
184
|
+
Effect.logWarning(`calling ${secretUrl}`).pipe(
|
|
185
|
+
Effect.as({ name: 'x' }),
|
|
186
|
+
Effect.withSpan('rt.logging'),
|
|
187
|
+
),
|
|
188
|
+
delete: () => Effect.void,
|
|
189
|
+
});
|
|
190
|
+
const providers = Layer.mergeAll(
|
|
191
|
+
loggingProvider,
|
|
192
|
+
telemetryLayer({
|
|
193
|
+
endpoints: { traces: `${otlp.url}/v1/traces` },
|
|
194
|
+
redaction: { denylist: ['private.example.test', 'secret-site-abc123'] },
|
|
195
|
+
serviceName: 'rt',
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
await engineOver(providers).deploy(Thing('t', { name: 'x' }));
|
|
199
|
+
// effect's default `tracerLogger` turns this into a span EVENT whose NAME is the log message —
|
|
200
|
+
// `wrapTracer.event()` redacts the attributes but passed the name straight through before this fix.
|
|
201
|
+
expect(otlp.sawText('SECRET123')).toBe(false);
|
|
202
|
+
expect(otlp.sawText('secret-site-abc123')).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Layer factory for OTLP tracing/logging/metrics, built for a stack's own `providers` Layer
|
|
3
|
+
* rather than the CLI-wide install alchemy's own `Telemetry/Layer.ts` does (`src/Telemetry/
|
|
4
|
+
* Layer.ts:13-15`, hard-coded to `otel.alchemy.run` with no override). Nothing here calls that
|
|
5
|
+
* endpoint or knows it exists.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ NO DEFAULTS THAT POINT ANYWHERE. `endpoints` is `{}` by default and `telemetryLayer` resolves
|
|
8
|
+
* to `Layer.empty` when every entry is `undefined` — see layer.test.ts's own "nothing sent
|
|
9
|
+
* when unconfigured". A consumer wires actual hosts from ITS OWN site config; see docs/telemetry.md.
|
|
10
|
+
* ★ SEALED TRANSPORT — the caddy lesson (kit PR 293, admin.ts's own ⛔): merging a layer that still
|
|
11
|
+
* requires `HttpClient.HttpClient` into a stack's `providers` would let this module's own
|
|
12
|
+
* `FetchHttpClient` shadow a sibling fetch-based provider's client once both are merged into the
|
|
13
|
+
* same `Layer.mergeAll`. `Layer.provide` (not `Layer.provideMerge`) twice below feeds
|
|
14
|
+
* `FetchHttpClient`/`OtlpSerialization` to the OTLP layers and returns ONLY their own output —
|
|
15
|
+
* `HttpClient.HttpClient` never appears in what `telemetryLayer` hands back. See
|
|
16
|
+
* http-client-sealing.test.ts.
|
|
17
|
+
* ★ PER-SIGNAL URLS, NOT ONE BASE URL. effect's own `Otlp.layer`/`layerJson` take a single
|
|
18
|
+
* `baseUrl` and post to `<baseUrl>/v1/{traces,metrics,logs}` — fine when one collector serves all
|
|
19
|
+
* three, wrong for Victoria's split binaries (VictoriaTraces/Logs/Metrics on three different
|
|
20
|
+
* ports/paths — docs/telemetry.md has the exact paths). `OtlpTracer`/`OtlpLogger`/`OtlpMetrics`
|
|
21
|
+
* each take their own `url` and post to it verbatim (OtlpExporter.ts `make`: `HttpClientRequest.
|
|
22
|
+
* post(options.url, …)`, no suffix appended) — exactly the shape three independent endpoints need.
|
|
23
|
+
* ⛔ PROTOBUF, NOT JSON (redteam PR 302 CRITICAL item 1, 2026-09-26, measured against the pinned
|
|
24
|
+
* binaries in homeflare-mini's `src/obs`): VictoriaLogs 1.52.0 and VictoriaMetrics 1.151.0 both
|
|
25
|
+
* answer OTLP/HTTP JSON with `400 "json encoding isn't supported for opentelemetry format"` —
|
|
26
|
+
* effect's exporter treats a 400 as non-transient, drops the batch silently and disables itself for
|
|
27
|
+
* 60s at debug-only log level, so a JSON-encoded consumer sees traces (VictoriaTraces alone
|
|
28
|
+
* tolerates JSON) but never logs or metrics, with no visible error. `redactedSerialization` below
|
|
29
|
+
* provides `OtlpSerialization.layerProtobuf`, which all three ingested in the same measurement.
|
|
30
|
+
*/
|
|
31
|
+
import type * as Duration from 'effect/Duration';
|
|
32
|
+
import * as Effect from 'effect/Effect';
|
|
33
|
+
import * as Layer from 'effect/Layer';
|
|
34
|
+
import * as Logger from 'effect/Logger';
|
|
35
|
+
import * as LogLevel from 'effect/LogLevel';
|
|
36
|
+
import * as Tracer from 'effect/Tracer';
|
|
37
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
38
|
+
import { OtlpExporter, OtlpLogger, OtlpMetrics, OtlpTracer } from 'effect/unstable/observability';
|
|
39
|
+
import { redactedSerialization } from './redact-serialization.ts';
|
|
40
|
+
import { type RedactionPolicy, wrapTracer } from './redact.ts';
|
|
41
|
+
|
|
42
|
+
/** Full OTLP/HTTP endpoint per signal — e.g. `http://victoria.example.test:10428/insert/opentelemetry/v1/traces`. Any entry left `undefined` sends nothing for that signal. */
|
|
43
|
+
export interface TelemetryEndpoints {
|
|
44
|
+
readonly traces?: string;
|
|
45
|
+
readonly logs?: string;
|
|
46
|
+
readonly metrics?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TelemetryOptions {
|
|
50
|
+
readonly endpoints: TelemetryEndpoints;
|
|
51
|
+
/** OTLP `service.name` — required, since an unnamed service in a shared Victoria instance is unusable. */
|
|
52
|
+
readonly serviceName: string;
|
|
53
|
+
readonly serviceVersion?: string;
|
|
54
|
+
/** Extra OTLP resource attributes (e.g. `{ "deployment.environment": "prod" }`). */
|
|
55
|
+
readonly resourceAttributes?: Record<string, unknown>;
|
|
56
|
+
readonly redaction?: RedactionPolicy;
|
|
57
|
+
/**
|
|
58
|
+
* How often a signal flushes its buffer.
|
|
59
|
+
* @default '1 second' — short on purpose: alchemy invocations are CLI runs, often shorter than a
|
|
60
|
+
* typical OTLP batch interval, so the export must fire well before the process could exit. The
|
|
61
|
+
* scope's own shutdown flush (`Exporter.layerFlusher`) is the backstop for whatever this interval
|
|
62
|
+
* misses; docs/telemetry-spike.md measured both paths.
|
|
63
|
+
*/
|
|
64
|
+
readonly exportInterval?: Duration.Input;
|
|
65
|
+
/**
|
|
66
|
+
* Floor below which a log record never reaches the OTLP logger.
|
|
67
|
+
* @default 'Info' — redteam PR 302 IMPORTANT item 4: the `alchemy` CLI sets the GLOBAL minimum
|
|
68
|
+
* log level to Debug (`GlobalLog.ts`), and `OtlpLogger` has no floor of its own, so without one
|
|
69
|
+
* every Debug-level record (a provider's own connection-string/config dump, annotation noise)
|
|
70
|
+
* would ship — once `redactedSerialization` fixed item 1's silent-JSON-failure, they would
|
|
71
|
+
* actually start arriving. This floor applies ONLY to what THIS layer exports; a consumer's
|
|
72
|
+
* console/file loggers keep whatever level the CLI's own global setting gives them, since
|
|
73
|
+
* `logsMinimumLevel` filters after `Logger.layer`'s `mergeWithExisting: true` has already added
|
|
74
|
+
* this logger alongside them, not by lowering that global setting.
|
|
75
|
+
*/
|
|
76
|
+
readonly logsMinimumLevel?: LogLevel.LogLevel;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ⚠️ `exactOptionalPropertyTypes` — omit a key entirely rather than set it `undefined`, or the
|
|
80
|
+
// OTLP layers' own `{ readonly attributes?: X }` (no `| undefined` in their own signatures)
|
|
81
|
+
// rejects the object.
|
|
82
|
+
const resourceOf = (options: TelemetryOptions) => ({
|
|
83
|
+
serviceName: options.serviceName,
|
|
84
|
+
...(options.serviceVersion !== undefined ? { serviceVersion: options.serviceVersion } : {}),
|
|
85
|
+
...(options.resourceAttributes !== undefined ? { attributes: options.resourceAttributes } : {}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `OtlpTracer.layer`'s own composition (OtlpTracer.ts: `flow(make, Layer.effect(Tracer.Tracer),
|
|
90
|
+
* Layer.provideMerge(Exporter.layerFlusher))`), with `wrapTracer` inserted between `make` and
|
|
91
|
+
* `Layer.effect` so every span this tracer creates is redacted before anything else can see it.
|
|
92
|
+
*/
|
|
93
|
+
const tracerLayer = (url: string, options: TelemetryOptions, exportInterval: Duration.Input) =>
|
|
94
|
+
Layer.effect(
|
|
95
|
+
Tracer.Tracer,
|
|
96
|
+
Effect.map(OtlpTracer.make({ exportInterval, resource: resourceOf(options), url }), (inner) =>
|
|
97
|
+
wrapTracer(inner, options.redaction ?? {}),
|
|
98
|
+
),
|
|
99
|
+
).pipe(Layer.provideMerge(OtlpExporter.layerFlusher));
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `OtlpLogger.layer`'s own composition (`Logger.layer([make(options)], { mergeWithExisting: true })
|
|
103
|
+
* .pipe(Layer.provideMerge(Exporter.layerFlusher))`), with a level floor inserted between `make` and
|
|
104
|
+
* `Logger.layer` — see `TelemetryOptions.logsMinimumLevel`'s own doc for why.
|
|
105
|
+
*/
|
|
106
|
+
const loggerLayer = (url: string, options: TelemetryOptions, exportInterval: Duration.Input) => {
|
|
107
|
+
const floor = options.logsMinimumLevel ?? 'Info';
|
|
108
|
+
return Logger.layer(
|
|
109
|
+
[
|
|
110
|
+
Effect.map(OtlpLogger.make({ exportInterval, resource: resourceOf(options), url }), (inner) =>
|
|
111
|
+
Logger.make((record) =>
|
|
112
|
+
LogLevel.isGreaterThanOrEqualTo(record.logLevel, floor) ? inner.log(record) : undefined,
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
],
|
|
116
|
+
{ mergeWithExisting: true },
|
|
117
|
+
).pipe(Layer.provideMerge(OtlpExporter.layerFlusher));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The Layer a consumer stack merges into its own `providers` — see docs/telemetry.md for how a
|
|
122
|
+
* site file wires real hosts in, and docs/telemetry-spike.md for what a stack's `providers` Layer does and does
|
|
123
|
+
* not carry a span for.
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ FIXED THREE-ARG `Layer.mergeAll`, NOT AN ARRAY `.reduce`. Every signal slot is filled — with
|
|
126
|
+
* the real per-signal layer when its endpoint is set, `Layer.empty` when it is not — instead of
|
|
127
|
+
* building a variable-length array and folding it with `Layer.merge`. `Layer<in ROut, …>`
|
|
128
|
+
* declares its success channel CONTRAVARIANT, and once a fold's accumulator needs an explicit
|
|
129
|
+
* annotation to type-check under this repo's `exactOptionalPropertyTypes`, TS's overload
|
|
130
|
+
* resolution for `Layer.provide`/`Layer.merge` starts inferring `unknown` for that channel and
|
|
131
|
+
* then fails `unknown` against `never` (measured 2026-09-26, TS2769). `Layer.mergeAll`'s own
|
|
132
|
+
* fixed-arity overload — the exact shape alchemy's own `Telemetry/Layer.ts` uses for its three
|
|
133
|
+
* signals — infers cleanly with no annotation at all.
|
|
134
|
+
*/
|
|
135
|
+
export const telemetryLayer = (options: TelemetryOptions): Layer.Layer<never> => {
|
|
136
|
+
const { traces, logs, metrics } = options.endpoints;
|
|
137
|
+
if (traces === undefined && logs === undefined && metrics === undefined) return Layer.empty;
|
|
138
|
+
// Default declared here, not in the options' type, so every call site above stays a concrete
|
|
139
|
+
// `Duration.Input` — `exactOptionalPropertyTypes` rejects passing `undefined` through to the
|
|
140
|
+
// OTLP layers' own `exportInterval?: Duration.Input` (no `| undefined` in their signatures).
|
|
141
|
+
const exportInterval: Duration.Input = options.exportInterval ?? '1 second';
|
|
142
|
+
const tracer = traces === undefined ? Layer.empty : tracerLayer(traces, options, exportInterval);
|
|
143
|
+
const logger = logs === undefined ? Layer.empty : loggerLayer(logs, options, exportInterval);
|
|
144
|
+
const meter =
|
|
145
|
+
metrics === undefined
|
|
146
|
+
? Layer.empty
|
|
147
|
+
: OtlpMetrics.layer({ exportInterval, resource: resourceOf(options), url: metrics });
|
|
148
|
+
return Layer.mergeAll(tracer, logger, meter).pipe(
|
|
149
|
+
Layer.provide(redactedSerialization(options.redaction ?? {})),
|
|
150
|
+
Layer.provide(FetchHttpClient.layer),
|
|
151
|
+
);
|
|
152
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free-text OTLP redaction applied at `OtlpSerialization` — the ONE place every span/log field
|
|
3
|
+
* reaches the wire, right before HTTP body encoding. `redact.ts`'s `wrapTracer` only sees what a
|
|
4
|
+
* caller passes to `Span.attribute()`/`Span.event()`; two real fields never go through either hook:
|
|
5
|
+
*
|
|
6
|
+
* - `status.message` and the `exception.*` event attributes `OtlpTracer`'s own `makeOtlpSpan` builds
|
|
7
|
+
* from `Cause.prettyErrors(exit.cause)` — computed at export time, inside effect's tracer, AFTER
|
|
8
|
+
* `wrapTracer.end()` has already handed the exit straight to `real.end()`.
|
|
9
|
+
* - a log line inside a span, turned into a span event by effect's default `tracerLogger` — the log
|
|
10
|
+
* message becomes the event NAME (not an attribute `wrapTracer.event()` redacts), and its
|
|
11
|
+
* `effect.cause` attribute is free text `redactAttribute`'s key-based rules never recognize.
|
|
12
|
+
*
|
|
13
|
+
* Redteam finding (PR 302, 2026-09-26): a provider GET that 404'd exported `exception.message =
|
|
14
|
+
* "... 404 GET http://host/secret-site-abc123/probe?token=SECRET123"` unredacted — the query, the
|
|
15
|
+
* denylisted path segment and the denylisted host all went out. A `logWarning` inside a span leaked
|
|
16
|
+
* the same way through the event name.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ SUBSTRING MATCH, NOT SEGMENT MATCH. `redactAttribute` compares a denylist entry against a whole
|
|
19
|
+
* `/`-bounded URL segment on purpose (`redact.ts`'s own ⚠️: `"db"` must not blank `"db1.example.
|
|
20
|
+
* test"`). Free text has no segment boundaries — a log message or a formatted `Cause` embeds a
|
|
21
|
+
* token in prose — so `scrubText` matches a denylist entry as a literal substring ANYWHERE in the
|
|
22
|
+
* string. This is strictly broader than the structured rules, so it also closes the `server.
|
|
23
|
+
* address` bare-hostname and legacy `http.url` gaps (redteam MINOR item 5) as a side effect: a
|
|
24
|
+
* denylisted hostname is a substring match wherever it appears, URL-shaped or not. The tradeoff a
|
|
25
|
+
* caller accepts by naming a short, common word in `denylist` is over-redaction, never a leak.
|
|
26
|
+
*/
|
|
27
|
+
import * as Effect from 'effect/Effect';
|
|
28
|
+
import * as Layer from 'effect/Layer';
|
|
29
|
+
import { OtlpSerialization } from 'effect/unstable/observability';
|
|
30
|
+
import type { OtlpLogger, OtlpResource, OtlpTracer } from 'effect/unstable/observability';
|
|
31
|
+
import { REDACTED, type RedactionPolicy } from './redact.ts';
|
|
32
|
+
|
|
33
|
+
const URL_PATTERN = /https?:\/\/[^\s"'<>)]+/g;
|
|
34
|
+
|
|
35
|
+
/** Strips an embedded URL's query/hash and blanks its denylisted host/path segments — `redact.ts`'s
|
|
36
|
+
* `url.full` case, reused here because a URL can appear inside prose, not just as its own attribute. */
|
|
37
|
+
const scrubUrl = (raw: string, denylist: ReadonlySet<string>): string => {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(raw);
|
|
40
|
+
const host = denylist.has(url.hostname) ? REDACTED : url.host;
|
|
41
|
+
const path = url.pathname
|
|
42
|
+
.split('/')
|
|
43
|
+
.map((segment) => (denylist.has(segment) ? REDACTED : segment))
|
|
44
|
+
.join('/');
|
|
45
|
+
return `${url.protocol}//${host}${path}`;
|
|
46
|
+
} catch {
|
|
47
|
+
return raw; // Matched the URL_PATTERN regex but `URL` still rejects it — leave verbatim.
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Redacts one piece of free text: every embedded URL loses its query string and denylisted
|
|
52
|
+
* segments, then every denylist entry is blanked as a substring anywhere else in the text. */
|
|
53
|
+
export const scrubText = (text: string, policy: RedactionPolicy): string => {
|
|
54
|
+
const denylist = policy.denylist ?? [];
|
|
55
|
+
const denylistSet = new Set(denylist);
|
|
56
|
+
let out = text.replace(URL_PATTERN, (match) => scrubUrl(match, denylistSet));
|
|
57
|
+
for (const token of denylist) {
|
|
58
|
+
if (token.length === 0) continue; // an empty entry would blank every character boundary
|
|
59
|
+
out = out.split(token).join(REDACTED);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Walks one OTLP `AnyValue`'s string leaves (`stringValue`, and recursively through `arrayValue`/
|
|
65
|
+
* `kvlistValue`) — the union effect's OTLP encoders use for every attribute and log body value. */
|
|
66
|
+
const scrubValue = (
|
|
67
|
+
value: OtlpResource.AnyValue,
|
|
68
|
+
policy: RedactionPolicy,
|
|
69
|
+
): OtlpResource.AnyValue => {
|
|
70
|
+
if (typeof value.stringValue === 'string')
|
|
71
|
+
return { ...value, stringValue: scrubText(value.stringValue, policy) };
|
|
72
|
+
if (value.arrayValue !== undefined) {
|
|
73
|
+
return {
|
|
74
|
+
...value,
|
|
75
|
+
arrayValue: { values: value.arrayValue.values.map((v) => scrubValue(v, policy)) },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (value.kvlistValue !== undefined) {
|
|
79
|
+
return {
|
|
80
|
+
...value,
|
|
81
|
+
kvlistValue: {
|
|
82
|
+
values: value.kvlistValue.values.map((kv) => ({
|
|
83
|
+
key: kv.key,
|
|
84
|
+
value: scrubValue(kv.value, policy),
|
|
85
|
+
})),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return value; // boolValue / intValue / doubleValue / bytesValue — never free text.
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const scrubKeyValues = (
|
|
93
|
+
attributes: ReadonlyArray<OtlpResource.KeyValue>,
|
|
94
|
+
policy: RedactionPolicy,
|
|
95
|
+
): Array<OtlpResource.KeyValue> =>
|
|
96
|
+
attributes.map((kv) => ({ key: kv.key, value: scrubValue(kv.value, policy) }));
|
|
97
|
+
|
|
98
|
+
/** `TraceData` after every span's name, attributes, events (including the `exception.*` ones
|
|
99
|
+
* `makeOtlpSpan` builds from the exit), status message and link attributes have been scrubbed. */
|
|
100
|
+
export const redactTraceData = (
|
|
101
|
+
data: OtlpTracer.TraceData,
|
|
102
|
+
policy: RedactionPolicy,
|
|
103
|
+
): OtlpTracer.TraceData => ({
|
|
104
|
+
resourceSpans: data.resourceSpans.map((resourceSpan) => ({
|
|
105
|
+
...resourceSpan,
|
|
106
|
+
scopeSpans: resourceSpan.scopeSpans.map((scopeSpan) => ({
|
|
107
|
+
...scopeSpan,
|
|
108
|
+
spans: scopeSpan.spans.map((span) => ({
|
|
109
|
+
...span,
|
|
110
|
+
attributes: scrubKeyValues(span.attributes, policy),
|
|
111
|
+
events: span.events.map((event) => ({
|
|
112
|
+
...event,
|
|
113
|
+
attributes: scrubKeyValues(event.attributes, policy),
|
|
114
|
+
name: scrubText(event.name, policy),
|
|
115
|
+
})),
|
|
116
|
+
links: span.links.map((link) => ({
|
|
117
|
+
...link,
|
|
118
|
+
attributes: scrubKeyValues(link.attributes, policy),
|
|
119
|
+
})),
|
|
120
|
+
name: scrubText(span.name, policy),
|
|
121
|
+
status:
|
|
122
|
+
span.status.message === undefined
|
|
123
|
+
? span.status
|
|
124
|
+
: { ...span.status, message: scrubText(span.status.message, policy) },
|
|
125
|
+
})),
|
|
126
|
+
})),
|
|
127
|
+
})),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
type LogRecord = NonNullable<
|
|
131
|
+
OtlpLogger.LogsData['resourceLogs'][number]['scopeLogs'][number]['logRecords']
|
|
132
|
+
>[number];
|
|
133
|
+
|
|
134
|
+
// ⚠️ `exactOptionalPropertyTypes` — `body` is `AnyValue?` with no `| undefined` in its own
|
|
135
|
+
// signature (matches `layer.ts`'s own `resourceOf` note), so an absent one is OMITTED, not set to
|
|
136
|
+
// `undefined`, or this object-literal return fails to type-check against `ILogRecord`.
|
|
137
|
+
const scrubLogRecord = (record: LogRecord, policy: RedactionPolicy): LogRecord => ({
|
|
138
|
+
...record,
|
|
139
|
+
attributes: scrubKeyValues(record.attributes, policy),
|
|
140
|
+
...(record.body !== undefined ? { body: scrubValue(record.body, policy) } : {}),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** `LogsData` after every log record's attributes and body have been scrubbed — the fix for redteam
|
|
144
|
+
* item 4's "logs ship unredacted": `log.error` (`Cause.pretty`) and the message body are free text. */
|
|
145
|
+
export const redactLogsData = (
|
|
146
|
+
data: OtlpLogger.LogsData,
|
|
147
|
+
policy: RedactionPolicy,
|
|
148
|
+
): OtlpLogger.LogsData => ({
|
|
149
|
+
resourceLogs: data.resourceLogs.map((resourceLog) => ({
|
|
150
|
+
...resourceLog,
|
|
151
|
+
scopeLogs: resourceLog.scopeLogs.map((scopeLog) => ({
|
|
152
|
+
...scopeLog,
|
|
153
|
+
// Same `exactOptionalPropertyTypes` reasoning as `scrubLogRecord` — omit `logRecords`
|
|
154
|
+
// entirely rather than assign it `undefined` when the source had none.
|
|
155
|
+
...(scopeLog.logRecords !== undefined
|
|
156
|
+
? { logRecords: scopeLog.logRecords.map((record) => scrubLogRecord(record, policy)) }
|
|
157
|
+
: {}),
|
|
158
|
+
})),
|
|
159
|
+
})),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Wraps the real `OtlpSerialization` so `telemetryLayer` gets this free-text pass on every signal
|
|
164
|
+
* with no separate wiring at each call site — `layer.ts` provides this in place of a bare
|
|
165
|
+
* `OtlpSerialization.layerProtobuf`. Metrics pass through untouched: they carry numeric data points
|
|
166
|
+
* keyed by `Metric.Metric.AttributeSet`, not provider-supplied free text.
|
|
167
|
+
*/
|
|
168
|
+
export const redactedSerialization = (
|
|
169
|
+
policy: RedactionPolicy,
|
|
170
|
+
): Layer.Layer<OtlpSerialization.OtlpSerialization> =>
|
|
171
|
+
Layer.effect(
|
|
172
|
+
OtlpSerialization.OtlpSerialization,
|
|
173
|
+
Effect.map(OtlpSerialization.OtlpSerialization, (inner) => ({
|
|
174
|
+
logs: (data: OtlpLogger.LogsData) => inner.logs(redactLogsData(data, policy)),
|
|
175
|
+
metrics: inner.metrics,
|
|
176
|
+
traces: (data: OtlpTracer.TraceData) => inner.traces(redactTraceData(data, policy)),
|
|
177
|
+
})),
|
|
178
|
+
).pipe(Layer.provide(OtlpSerialization.layerProtobuf));
|