From 1fd056a52a52c6cd47dcc6e789f460b8a7181921 Mon Sep 17 00:00:00 2001 From: Ben Elferink Date: Thu, 25 Jun 2026 12:49:46 +0300 Subject: [PATCH 01/14] feat: add browser instrumentation - Introduced a new browser proxy component to inject OpenTelemetry SDK into served HTML and forward telemetry to the Odigos collector. - Updated Makefile to include build and push targets for the browser proxy. - Added GitHub Actions workflow for building and testing the browser proxy image. - Enhanced instrumentation configuration to support browser as a new agent category. - Implemented necessary server logic for handling OTLP telemetry from the browser. This commit lays the groundwork for improved browser instrumentation and telemetry collection. --- .github/workflows/build.yaml | 47 ++++ .../update-instrumentation-agents-version.yml | 2 +- Makefile | 14 +- api/config/crd/bases/odigos.io_actions.yaml | 3 + .../odigos.io_instrumentationconfigs.yaml | 5 + .../bases/odigos.io_instrumentationrules.yaml | 4 + api/config/crd/bases/odigos.io_samplings.yaml | 3 + api/config/crd/bases/odigos.io_sources.yaml | 1 + api/k8sconsts/browserproxy.go | 67 ++++++ browser-proxy/Dockerfile | 43 ++++ browser-proxy/LICENSE | 201 ++++++++++++++++++ browser-proxy/Makefile | 32 +++ browser-proxy/cmd/main.go | 53 +++++ browser-proxy/go.mod | 3 + browser-proxy/internal/config/config.go | 121 +++++++++++ browser-proxy/internal/iptables/iptables.go | 49 +++++ browser-proxy/internal/server/inject.go | 132 ++++++++++++ browser-proxy/internal/server/inject_test.go | 98 +++++++++ browser-proxy/internal/server/otlp.go | 83 ++++++++ browser-proxy/internal/server/server.go | 180 ++++++++++++++++ browser-proxy/internal/server/server_test.go | 146 +++++++++++++ common/lang_detection.go | 23 +- distros/distro/oteldistribution.go | 22 ++ distros/oteldistributions.go | 1 + distros/yamls/browser-community.yaml | 26 +++ docs/docs.json | 7 + docs/oss/instrumentations/browser/native.mdx | 82 +++++++ .../templates/crds/odigos.io_actions.yaml | 3 + .../odigos.io_instrumentationconfigs.yaml | 5 + .../crds/odigos.io_instrumentationrules.yaml | 4 + .../templates/crds/odigos.io_samplings.yaml | 3 + .../templates/crds/odigos.io_sources.yaml | 1 + .../templates/instrumentor/deployment.yaml | 2 + .../controllers/agentenabled/browser_proxy.go | 144 +++++++++++++ .../distroresolver/distroresolver.go | 13 +- .../distroresolver/distroresolver_test.go | 47 ++++ .../controllers/agentenabled/pods_webhook.go | 52 +++++ odiglet/Dockerfile | 5 + odiglet/agent-deps.mk | 2 + odiglet/debug.Dockerfile | 3 + .../pkg/inspectors/browser/browser.go | 48 +++++ .../browser-instrumentation/01-frontend.yaml | 62 ++++++ .../browser-instrumentation/02-source.yaml | 15 ++ .../03-assert-sidecar-injected.yaml | 24 +++ tests/e2e/browser-instrumentation/README.md | 33 +++ .../chainsaw-test.yaml | 43 ++++ 46 files changed, 1944 insertions(+), 13 deletions(-) create mode 100644 api/k8sconsts/browserproxy.go create mode 100644 browser-proxy/Dockerfile create mode 100644 browser-proxy/LICENSE create mode 100644 browser-proxy/Makefile create mode 100644 browser-proxy/cmd/main.go create mode 100644 browser-proxy/go.mod create mode 100644 browser-proxy/internal/config/config.go create mode 100644 browser-proxy/internal/iptables/iptables.go create mode 100644 browser-proxy/internal/server/inject.go create mode 100644 browser-proxy/internal/server/inject_test.go create mode 100644 browser-proxy/internal/server/otlp.go create mode 100644 browser-proxy/internal/server/server.go create mode 100644 browser-proxy/internal/server/server_test.go create mode 100644 distros/yamls/browser-community.yaml create mode 100644 docs/oss/instrumentations/browser/native.mdx create mode 100644 instrumentor/controllers/agentenabled/browser_proxy.go create mode 100644 procdiscovery/pkg/inspectors/browser/browser.go create mode 100644 tests/e2e/browser-instrumentation/01-frontend.yaml create mode 100644 tests/e2e/browser-instrumentation/02-source.yaml create mode 100644 tests/e2e/browser-instrumentation/03-assert-sidecar-injected.yaml create mode 100644 tests/e2e/browser-instrumentation/README.md create mode 100644 tests/e2e/browser-instrumentation/chainsaw-test.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2f502e8f2b..c4fa282db6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -96,6 +96,52 @@ jobs: path: /tmp/scan-results/odigos-scheduler.json retention-days: 30 + build-browser-proxy: + name: build-browser-proxy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Browser Proxy Image + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64 + push: false + tags: browser-proxy:pr-${{ github.event.number || github.run_number }} + provenance: false + file: browser-proxy/Dockerfile + outputs: type=docker,dest=/tmp/browser-proxy.tar + - name: Load Docker image for scanning + run: docker load -i /tmp/browser-proxy.tar + - name: Scan image for vulnerabilities + id: scan-browser-proxy + uses: odigos-io/ci-core/vulnerabilities-scanner@main + with: + image: browser-proxy:pr-${{ github.event.number || github.run_number }} + severity-cutoff: "CRITICAL" + continue-on-error: true + - name: Save browser-proxy scan JSON + if: always() + run: | + mkdir -p /tmp/scan-results + cat > /tmp/scan-results/odigos-browser-proxy.json << 'EOF' + ${{ steps.scan-browser-proxy.outputs.results-json }} + EOF + jq --arg img "odigos-browser-proxy" --arg tag "pr-${{ github.event.number || github.run_number }}" '. + {imageName: $img, imageTag: $tag}' /tmp/scan-results/odigos-browser-proxy.json > /tmp/scan-results/odigos-browser-proxy_enriched.json + mv /tmp/scan-results/odigos-browser-proxy_enriched.json /tmp/scan-results/odigos-browser-proxy.json + - name: Upload browser-proxy scan results + if: always() + uses: actions/upload-artifact@v4 + with: + name: vuln-scan-result-browser-proxy-${{ github.sha }}.json + path: /tmp/scan-results/odigos-browser-proxy.json + retention-days: 30 + - name: run tests + working-directory: ./browser-proxy + run: | + make test + build-agents: name: build-agents runs-on: depot-ubuntu-latest @@ -420,6 +466,7 @@ jobs: needs: - build-and-test-autoscaler - build-scheduler + - build-browser-proxy - build-agents - build-and-test-instrumentor - build-and-test-odigos-collector diff --git a/.github/workflows/update-instrumentation-agents-version.yml b/.github/workflows/update-instrumentation-agents-version.yml index f39dedd8f1..a8ec96c994 100644 --- a/.github/workflows/update-instrumentation-agents-version.yml +++ b/.github/workflows/update-instrumentation-agents-version.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: instrumentation_agent: - description: "Canonical agent category (e.g. python, php, ruby, nodejs)" + description: "Canonical agent category (e.g. python, php, ruby, nodejs, browser)" required: true type: string new_agent_version: diff --git a/Makefile b/Makefile index 3372921712..ffc3a389e7 100644 --- a/Makefile +++ b/Makefile @@ -153,6 +153,10 @@ build-instrumentor: build-scheduler: $(MAKE) build-image/scheduler SUMMARY="Scheduler for Odigos" DESCRIPTION="Scheduler manages the installation of OpenTelemetry Collectors with Odigos." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) +.PHONY: build-browser-proxy +build-browser-proxy: + $(MAKE) build-image/browser-proxy DOCKERFILE=browser-proxy/$(DOCKERFILE) SUMMARY="Browser proxy for Odigos" DESCRIPTION="Sidecar that injects the OpenTelemetry browser SDK into served HTML and proxies browser telemetry to the Odigos collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) + .PHONY: build-collector build-collector: $(MAKE) build-image/collector DOCKERFILE=collector/$(DOCKERFILE) SUMMARY="Odigos Collector" DESCRIPTION="The Odigos build of the OpenTelemetry Collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) @@ -171,7 +175,7 @@ verify-nodejs-agent: .PHONY: build-images build-images: # prefer to build timeconsuimg images first to make better use of parallelism - make -j $(nproc) build-ui build-collector build-odiglet build-autoscaler build-scheduler build-instrumentor build-agents TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) + make -j $(nproc) build-ui build-collector build-odiglet build-autoscaler build-scheduler build-instrumentor build-browser-proxy build-agents TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) .PHONY: build-images-rhel build-images-rhel: @@ -211,6 +215,10 @@ push-instrumentor: push-scheduler: $(MAKE) push-image/scheduler SUMMARY="Scheduler for Odigos" DESCRIPTION="Scheduler manages the installation of OpenTelemetry Collectors with Odigos." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) +.PHONY: push-browser-proxy +push-browser-proxy: + $(MAKE) push-image/browser-proxy DOCKERFILE=browser-proxy/$(DOCKERFILE) SUMMARY="Browser proxy for Odigos" DESCRIPTION="Sidecar that injects the OpenTelemetry browser SDK into served HTML and proxies browser telemetry to the Odigos collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) + .PHONY: push-collector push-collector: $(MAKE) push-image/collector DOCKERFILE=collector/$(DOCKERFILE) BUILD_DIR=. SUMMARY="Odigos Collector" DESCRIPTION="The Odigos build of the OpenTelemetry Collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) @@ -225,7 +233,7 @@ push-agents: .PHONY: push-images push-images: - make push-autoscaler push-scheduler push-odiglet push-instrumentor push-collector push-ui TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) + make push-autoscaler push-scheduler push-odiglet push-instrumentor push-browser-proxy push-collector push-ui TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) .PHONY: push-images-rhel push-images-rhel: @@ -236,7 +244,7 @@ load-to-kind-%: .PHONY: load-to-kind load-to-kind: - make -j 6 load-to-kind-instrumentor load-to-kind-autoscaler load-to-kind-scheduler load-to-kind-odiglet load-to-kind-collector load-to-kind-ui load-to-kind-cli load-to-kind-agents ORG=$(ORG) TAG=$(TAG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) + make -j 6 load-to-kind-instrumentor load-to-kind-autoscaler load-to-kind-scheduler load-to-kind-odiglet load-to-kind-browser-proxy load-to-kind-collector load-to-kind-ui load-to-kind-cli load-to-kind-agents ORG=$(ORG) TAG=$(TAG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) .PHONY: restart-ui restart-ui: diff --git a/api/config/crd/bases/odigos.io_actions.yaml b/api/config/crd/bases/odigos.io_actions.yaml index 744f96e518..ce9fc733a7 100644 --- a/api/config/crd/bases/odigos.io_actions.yaml +++ b/api/config/crd/bases/odigos.io_actions.yaml @@ -304,6 +304,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -376,6 +377,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -468,6 +470,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/config/crd/bases/odigos.io_instrumentationconfigs.yaml b/api/config/crd/bases/odigos.io_instrumentationconfigs.yaml index 2df0549cda..f0b03dfac3 100644 --- a/api/config/crd/bases/odigos.io_instrumentationconfigs.yaml +++ b/api/config/crd/bases/odigos.io_instrumentationconfigs.yaml @@ -689,6 +689,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -727,6 +728,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -862,6 +864,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -1218,6 +1221,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -1838,6 +1842,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/config/crd/bases/odigos.io_instrumentationrules.yaml b/api/config/crd/bases/odigos.io_instrumentationrules.yaml index 8a2b04d4f0..2709438daf 100644 --- a/api/config/crd/bases/odigos.io_instrumentationrules.yaml +++ b/api/config/crd/bases/odigos.io_instrumentationrules.yaml @@ -223,6 +223,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -380,6 +381,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -447,6 +449,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -484,6 +487,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/config/crd/bases/odigos.io_samplings.yaml b/api/config/crd/bases/odigos.io_samplings.yaml index 69938a5e30..4b63118ce3 100644 --- a/api/config/crd/bases/odigos.io_samplings.yaml +++ b/api/config/crd/bases/odigos.io_samplings.yaml @@ -131,6 +131,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -279,6 +280,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -478,6 +480,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/config/crd/bases/odigos.io_sources.yaml b/api/config/crd/bases/odigos.io_sources.yaml index 8b54d0217f..8a7633f92c 100644 --- a/api/config/crd/bases/odigos.io_sources.yaml +++ b/api/config/crd/bases/odigos.io_sources.yaml @@ -120,6 +120,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/k8sconsts/browserproxy.go b/api/k8sconsts/browserproxy.go new file mode 100644 index 0000000000..204dbb426a --- /dev/null +++ b/api/k8sconsts/browserproxy.go @@ -0,0 +1,67 @@ +package k8sconsts + +// Contract shared between the instrumentor pod webhook (which injects the sidecar) and the +// odigos-browser-proxy binary (which runs as the sidecar). These MUST stay in sync; the +// browser-proxy module imports these constants directly. + +const ( + // BrowserProxyContainerName is the name of the sidecar container injected in front of a + // browser-instrumented web server. + BrowserProxyContainerName = "odigos-browser-proxy" + + // BrowserProxyInitContainerName is the name of the init container that sets up the iptables + // rules redirecting the application's inbound traffic to the sidecar. + BrowserProxyInitContainerName = "odigos-browser-proxy-init" + + // BrowserProxyRunAsUser is the UID the sidecar runs as. The iptables redirect excludes traffic + // owned by this UID so the sidecar can reach the application on loopback without looping. + BrowserProxyRunAsUser int64 = 1337 + + // BrowserProxyListenPort is the port the sidecar listens on. Inbound traffic to the application + // port is redirected here by the init container. + BrowserProxyListenPort = 15001 + + // Default location (under the agents directory) of the browser SDK bundle the sidecar serves. + BrowserProxyDefaultAgentDir = OdigosAgentsDirectory + "/browser" + BrowserProxyDefaultAgentFile = "agent.js" + + // Same-origin URL path prefix the sidecar reserves for itself. The application is assumed not to + // serve anything under this prefix. + BrowserProxyPathPrefix = "/__odigos/" + // Path (under the prefix) where the sidecar serves the browser SDK bundle. + BrowserProxyAgentJsPath = "/__odigos/agent.js" + // Path (under the prefix) where the sidecar receives OTLP/HTTP traces from the browser and + // forwards them to the node-local collector. + BrowserProxyTracesPath = "/__odigos/v1/traces" + + // Image name (without prefix/tag) of the browser-proxy sidecar. + OdigosBrowserProxyImage = "odigos-browser-proxy" + // Environment variable the instrumentor reads to override the browser-proxy image (set at install/upgrade). + OdigosBrowserProxyEnvVarName = "ODIGOS_BROWSER_PROXY_IMAGE" +) + +// Environment variables passed by the webhook to the sidecar / init container. +const ( + // Full http(s) URL of the application the sidecar forwards browser requests to (e.g. http://127.0.0.1:8080). + BrowserProxyUpstreamEnvVar = "ODIGOS_BROWSER_PROXY_UPSTREAM" + // Address the sidecar listens on (e.g. ":15001"). + BrowserProxyListenAddrEnvVar = "ODIGOS_BROWSER_PROXY_LISTEN_ADDR" + // Directory the sidecar serves the browser SDK bundle from (e.g. /var/odigos/browser). + BrowserProxyAgentDirEnvVar = "ODIGOS_BROWSER_PROXY_AGENT_DIR" + // File name (within the agent dir) of the browser SDK bundle (e.g. agent.js). + BrowserProxyAgentFileEnvVar = "ODIGOS_BROWSER_PROXY_AGENT_FILE" + // Base OTLP/HTTP endpoint of the node-local collector the sidecar forwards browser telemetry to + // (e.g. http://10.0.0.1:4318). The sidecar appends the OTLP signal path (e.g. /v1/traces). + BrowserProxyOtlpHttpEndpointEnvVar = "ODIGOS_BROWSER_PROXY_OTLP_HTTP_ENDPOINT" + // service.name reported by the browser SDK. + BrowserProxyServiceNameEnvVar = "ODIGOS_BROWSER_PROXY_SERVICE_NAME" + // OTEL_RESOURCE_ATTRIBUTES-style (key1=val1,key2=val2) resource attributes forwarded to the browser config. + BrowserProxyResourceAttributesEnvVar = "ODIGOS_BROWSER_PROXY_RESOURCE_ATTRIBUTES" + // Comma-separated list of URLs/regexes the browser SDK may attach trace-context headers to. + BrowserProxyPropagateCorsUrlsEnvVar = "ODIGOS_BROWSER_PROXY_PROPAGATE_CORS_URLS" + + // Init-container-only: the application's inbound port that should be redirected to the sidecar. + BrowserProxyAppPortEnvVar = "ODIGOS_BROWSER_PROXY_APP_PORT" + // Init-container-only: the UID the sidecar runs as (excluded from the iptables redirect). + BrowserProxyUidEnvVar = "ODIGOS_BROWSER_PROXY_UID" +) diff --git a/browser-proxy/Dockerfile b/browser-proxy/Dockerfile new file mode 100644 index 0000000000..09ef312ecd --- /dev/null +++ b/browser-proxy/Dockerfile @@ -0,0 +1,43 @@ +# Build context is the repository root (BUILD_DIR=.), matching the other Odigos components. +# Unlike the shared root Dockerfile, the browser-proxy image is alpine-based because the init +# mode needs the `iptables` binary to install the inbound traffic redirect. + +FROM --platform=$BUILDPLATFORM golang:1.26.2 AS builder +WORKDIR /workspace + +# browser-proxy is a self-contained, dependency-free module (stdlib only), so no sibling +# modules need to be copied. +COPY browser-proxy/ browser-proxy/ + +WORKDIR /workspace/browser-proxy +ARG TARGETARCH +ARG LD_FLAGS +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg \ + CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH \ + go build -ldflags="${LD_FLAGS}" -o /workspace/build/odigos-browser-proxy ./cmd + +######### Final image ######### +FROM alpine:3.20 +ARG VERSION +ARG RELEASE +ARG SUMMARY +ARG DESCRIPTION +LABEL "name"="odigos-browser-proxy" +LABEL "vendor"="Odigos" +LABEL "maintainer"="Odigos" +LABEL "version"=$VERSION +LABEL "release"=$RELEASE +LABEL "summary"=$SUMMARY +LABEL "description"=$DESCRIPTION + +# iptables is required by the `init` mode to redirect inbound traffic to the sidecar. +RUN apk add --no-cache iptables + +COPY --from=builder /workspace/build/odigos-browser-proxy /usr/local/bin/odigos-browser-proxy + +# The serve (sidecar) mode runs as this fixed UID; the iptables redirect excludes traffic owned by +# it so the sidecar can reach the application on loopback. The init mode is run as root with +# CAP_NET_ADMIN via the pod securityContext set by the instrumentor webhook. +USER 1337:1337 +ENTRYPOINT ["/usr/local/bin/odigos-browser-proxy"] diff --git a/browser-proxy/LICENSE b/browser-proxy/LICENSE new file mode 100644 index 0000000000..d164b5f728 --- /dev/null +++ b/browser-proxy/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Odigos + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/browser-proxy/Makefile b/browser-proxy/Makefile new file mode 100644 index 0000000000..195c44bafc --- /dev/null +++ b/browser-proxy/Makefile @@ -0,0 +1,32 @@ +.PHONY: all +all: build + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: test +test: fmt vet ## Run tests. + go test ./... -coverprofile cover.out + +.PHONY: build +build: fmt vet ## Build the browser-proxy binary. + go build -o bin/odigos-browser-proxy ./cmd + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +.PHONY: go-licenses +go-licenses: $(LOCALBIN) + GOBIN=$(LOCALBIN) go install github.com/google/go-licenses@latest + +.PHONY: licenses +licenses: go-licenses + rm -rf $(PWD)/licenses + $(LOCALBIN)/go-licenses save . --save_path=licenses diff --git a/browser-proxy/cmd/main.go b/browser-proxy/cmd/main.go new file mode 100644 index 0000000000..e0773fb907 --- /dev/null +++ b/browser-proxy/cmd/main.go @@ -0,0 +1,53 @@ +// Command odigos-browser-proxy runs as a sidecar in front of a browser-instrumented web server. +// +// It has two modes: +// +// odigos-browser-proxy - run the proxy server (default; the sidecar container) +// odigos-browser-proxy init - apply the iptables inbound redirect (the init container) +package main + +import ( + "log" + "os" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" + "github.com/odigos-io/odigos/browser-proxy/internal/iptables" + "github.com/odigos-io/odigos/browser-proxy/internal/server" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "init" { + runInit() + return + } + runServe() +} + +func runInit() { + cfg, err := config.LoadInit() + if err != nil { + log.Fatalf("browser-proxy init: invalid configuration: %v", err) + } + if err := iptables.Apply(iptables.Config{ + AppPort: cfg.AppPort, + ProxyPort: cfg.ProxyPort, + ProxyUID: cfg.ProxyUID, + }); err != nil { + log.Fatalf("browser-proxy init: failed to apply iptables redirect: %v", err) + } + log.Printf("browser-proxy init: redirected inbound tcp/%d -> sidecar tcp/%d", cfg.AppPort, cfg.ProxyPort) +} + +func runServe() { + cfg, err := config.LoadServe() + if err != nil { + log.Fatalf("browser-proxy: invalid configuration: %v", err) + } + srv, err := server.New(cfg) + if err != nil { + log.Fatalf("browser-proxy: failed to start: %v", err) + } + if err := srv.Run(); err != nil { + log.Fatalf("browser-proxy: server exited: %v", err) + } +} diff --git a/browser-proxy/go.mod b/browser-proxy/go.mod new file mode 100644 index 0000000000..31c3f57fdb --- /dev/null +++ b/browser-proxy/go.mod @@ -0,0 +1,3 @@ +module github.com/odigos-io/odigos/browser-proxy + +go 1.26.2 diff --git a/browser-proxy/internal/config/config.go b/browser-proxy/internal/config/config.go new file mode 100644 index 0000000000..331a0ad069 --- /dev/null +++ b/browser-proxy/internal/config/config.go @@ -0,0 +1,121 @@ +// Package config loads the odigos-browser-proxy sidecar configuration from environment variables. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Environment variable names. These MUST match the constants in +// github.com/odigos-io/odigos/api/k8sconsts (browserproxy.go), which the instrumentor webhook +// uses when injecting the sidecar. They are duplicated here so the sidecar stays a tiny, +// dependency-free module rather than pulling in the api/k8s module graph. +const ( + envUpstream = "ODIGOS_BROWSER_PROXY_UPSTREAM" + envListenAddr = "ODIGOS_BROWSER_PROXY_LISTEN_ADDR" + envAgentDir = "ODIGOS_BROWSER_PROXY_AGENT_DIR" + envAgentFile = "ODIGOS_BROWSER_PROXY_AGENT_FILE" + envOtlpHTTPEndpoint = "ODIGOS_BROWSER_PROXY_OTLP_HTTP_ENDPOINT" + envServiceName = "ODIGOS_BROWSER_PROXY_SERVICE_NAME" + envResourceAttributes = "ODIGOS_BROWSER_PROXY_RESOURCE_ATTRIBUTES" + envPropagateCorsUrls = "ODIGOS_BROWSER_PROXY_PROPAGATE_CORS_URLS" + envAppPort = "ODIGOS_BROWSER_PROXY_APP_PORT" + envProxyUID = "ODIGOS_BROWSER_PROXY_UID" +) + +// Defaults, also mirrored from api/k8sconsts. +const ( + DefaultListenPort = 15001 + DefaultAgentDir = "/var/odigos/browser" + DefaultAgentFile = "agent.js" + + // Same-origin paths the sidecar reserves for itself. + PathPrefix = "/__odigos/" + AgentJsPath = "/__odigos/agent.js" + TracesPath = "/__odigos/v1/traces" + OtlpPathPrefix = "/__odigos/v1/" +) + +// Config holds the resolved sidecar configuration. +type Config struct { + // ListenAddr is the address the sidecar's HTTP server binds to (e.g. ":15001"). + ListenAddr string + // Upstream is the application base URL the sidecar forwards browser requests to. + Upstream string + // AgentDir is the directory the browser SDK bundle is served from. + AgentDir string + // AgentFile is the file name of the browser SDK bundle within AgentDir. + AgentFile string + // OtlpHTTPEndpoint is the base OTLP/HTTP endpoint of the node-local collector. + OtlpHTTPEndpoint string + // ServiceName is reported as service.name by the browser SDK. + ServiceName string + // ResourceAttributes is an OTEL_RESOURCE_ATTRIBUTES-style string (k=v,k2=v2). + ResourceAttributes string + // PropagateCorsUrls is a comma-separated list of URLs/regexes for trace-context propagation. + PropagateCorsUrls string +} + +// LoadServe loads and validates the configuration needed to run the proxy server. +func LoadServe() (*Config, error) { + cfg := &Config{ + ListenAddr: getenvDefault(envListenAddr, fmt.Sprintf(":%d", DefaultListenPort)), + Upstream: os.Getenv(envUpstream), + AgentDir: getenvDefault(envAgentDir, DefaultAgentDir), + AgentFile: getenvDefault(envAgentFile, DefaultAgentFile), + OtlpHTTPEndpoint: strings.TrimRight(os.Getenv(envOtlpHTTPEndpoint), "/"), + ServiceName: os.Getenv(envServiceName), + ResourceAttributes: os.Getenv(envResourceAttributes), + PropagateCorsUrls: os.Getenv(envPropagateCorsUrls), + } + + if cfg.Upstream == "" { + return nil, fmt.Errorf("%s is required", envUpstream) + } + if cfg.OtlpHTTPEndpoint == "" { + return nil, fmt.Errorf("%s is required", envOtlpHTTPEndpoint) + } + + return cfg, nil +} + +// InitConfig holds the configuration for the iptables init mode. +type InitConfig struct { + AppPort int + ProxyPort int + ProxyUID int +} + +// LoadInit loads and validates the configuration needed to apply the iptables redirect. +func LoadInit() (*InitConfig, error) { + appPort, err := strconv.Atoi(os.Getenv(envAppPort)) + if err != nil || appPort <= 0 || appPort > 65535 { + return nil, fmt.Errorf("%s must be a valid TCP port, got %q", envAppPort, os.Getenv(envAppPort)) + } + + proxyUID, err := strconv.Atoi(os.Getenv(envProxyUID)) + if err != nil || proxyUID < 0 { + return nil, fmt.Errorf("%s must be a valid UID, got %q", envProxyUID, os.Getenv(envProxyUID)) + } + + proxyPort := DefaultListenPort + if v := os.Getenv(envListenAddr); v != "" { + // ListenAddr is ":15001"; extract the port. + if idx := strings.LastIndex(v, ":"); idx >= 0 { + if p, perr := strconv.Atoi(v[idx+1:]); perr == nil && p > 0 { + proxyPort = p + } + } + } + + return &InitConfig{AppPort: appPort, ProxyPort: proxyPort, ProxyUID: proxyUID}, nil +} + +func getenvDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/browser-proxy/internal/iptables/iptables.go b/browser-proxy/internal/iptables/iptables.go new file mode 100644 index 0000000000..709ee4219d --- /dev/null +++ b/browser-proxy/internal/iptables/iptables.go @@ -0,0 +1,49 @@ +// Package iptables applies the transparent inbound traffic redirect for the odigos-browser-proxy +// sidecar, modeled on the Istio init-container approach: inbound TCP destined to the application +// port is redirected to the sidecar's listen port, while the sidecar's own traffic (matched by UID) +// is excluded so it can reach the application on loopback without looping. +package iptables + +import ( + "fmt" + "os/exec" + "strconv" +) + +// Config parameters for the redirect. +type Config struct { + AppPort int + ProxyPort int + ProxyUID int +} + +// Apply installs the nat rules. It requires CAP_NET_ADMIN (the init container runs with that +// capability). The `iptables` binary must be present in the image. +func Apply(cfg Config) error { + appPort := strconv.Itoa(cfg.AppPort) + proxyPort := strconv.Itoa(cfg.ProxyPort) + uid := strconv.Itoa(cfg.ProxyUID) + + rules := [][]string{ + // Inbound (from outside the pod): redirect TCP to the application port -> sidecar port. + {"-t", "nat", "-A", "PREROUTING", "-p", "tcp", "--dport", appPort, "-j", "REDIRECT", "--to-ports", proxyPort}, + + // Locally generated traffic owned by the sidecar UID must NOT be redirected, so the sidecar + // can connect to the application on 127.0.0.1:. + {"-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", appPort, "-m", "owner", "--uid-owner", uid, "-j", "RETURN"}, + // Any other local process targeting the application port is redirected to the sidecar too, + // so in-pod clients are also instrumented consistently. + {"-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", appPort, "-j", "REDIRECT", "--to-ports", proxyPort}, + } + + for _, rule := range rules { + // "-w" makes iptables wait for the xtables lock instead of failing if it is held. + args := append([]string{"-w"}, rule...) + cmd := exec.Command("iptables", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("iptables %v failed: %w: %s", rule, err, string(out)) + } + } + + return nil +} diff --git a/browser-proxy/internal/server/inject.go b/browser-proxy/internal/server/inject.go new file mode 100644 index 0000000000..d7f25d1a4c --- /dev/null +++ b/browser-proxy/internal/server/inject.go @@ -0,0 +1,132 @@ +package server + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +// browserConfig is the JSON shape written to window.__ODIGOS__ for the agent bundle to read. +// It mirrors the OdigosBrowserConfig contract in the opentelemetry-browser agent (src/config.ts). +type browserConfig struct { + ServiceName string `json:"serviceName,omitempty"` + TracesPath string `json:"tracesPath"` + ResourceAttributes map[string]string `json:"resourceAttributes,omitempty"` + PropagateTraceHeaderCorsUrls []string `json:"propagateTraceHeaderCorsUrls,omitempty"` +} + +// buildSnippet renders the HTML that is injected into served pages: an inline script that sets +// window.__ODIGOS__, followed by the async safely (it escapes '<' as \u003c by default), + // preventing the inline JSON from prematurely closing the script tag. + b.Write(configJSON) + b.WriteString(";") + b.WriteString(``) + return b.Bytes(), nil +} + +// parseResourceAttributes parses an OTEL_RESOURCE_ATTRIBUTES-style string ("k1=v1,k2=v2"). +func parseResourceAttributes(raw string) map[string]string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + out := map[string]string{} + for _, pair := range strings.Split(raw, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + k, v, found := strings.Cut(pair, "=") + k = strings.TrimSpace(k) + if !found || k == "" { + continue + } + out[k] = strings.TrimSpace(v) + } + if len(out) == 0 { + return nil + } + return out +} + +func parseList(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var out []string + for _, v := range strings.Split(raw, ",") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out +} + +// injectIntoHTML inserts snippet into the document at the best available location. It prefers to +// inject immediately after the opening tag so the SDK initializes as early as possible. +// Matching is case-insensitive. If no suitable anchor is found, the snippet is prepended. +func injectIntoHTML(body, snippet []byte) []byte { + lower := bytes.ToLower(body) + + // Inject right after the opening tag. + if idx := indexAfterTag(lower, "= 0 { + return spliceAt(body, snippet, idx) + } + // Fall back to just before . + if idx := bytes.Index(lower, []byte("")); idx >= 0 { + return spliceAt(body, snippet, idx) + } + // Fall back to right after the opening tag. + if idx := indexAfterTag(lower, "= 0 { + return spliceAt(body, snippet, idx) + } + // Fall back to just before . + if idx := bytes.Index(lower, []byte("")); idx >= 0 { + return spliceAt(body, snippet, idx) + } + // Last resort: prepend. + return spliceAt(body, snippet, 0) +} + +// indexAfterTag returns the byte offset just past the end ('>') of the first tag that starts with +// tagStart (e.g. "') + if end < 0 { + return -1 + } + return start + end + 1 +} + +func spliceAt(body, snippet []byte, idx int) []byte { + out := make([]byte, 0, len(body)+len(snippet)) + out = append(out, body[:idx]...) + out = append(out, snippet...) + out = append(out, body[idx:]...) + return out +} diff --git a/browser-proxy/internal/server/inject_test.go b/browser-proxy/internal/server/inject_test.go new file mode 100644 index 0000000000..7b9ae0ced9 --- /dev/null +++ b/browser-proxy/internal/server/inject_test.go @@ -0,0 +1,98 @@ +package server + +import ( + "bytes" + "strings" + "testing" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +func TestInjectIntoHTML_AfterHead(t *testing.T) { + body := []byte("xhi") + snippet := []byte("") + out := string(injectIntoHTML(body, snippet)) + + if !strings.Contains(out, "") { + t.Fatalf("snippet not injected right after <head>: %s", out) + } +} + +func TestInjectIntoHTML_HeadWithAttributes(t *testing.T) { + body := []byte(`<head data-x="1">content`) + snippet := []byte("S") + out := string(injectIntoHTML(body, snippet)) + if !strings.HasPrefix(out, `<head data-x="1">S`) { + t.Fatalf("snippet not injected after head tag with attributes: %s", out) + } +} + +func TestInjectIntoHTML_FallbackBody(t *testing.T) { + body := []byte("<body>only body</body>") + snippet := []byte("S") + out := string(injectIntoHTML(body, snippet)) + if !strings.HasPrefix(out, "<body>S") { + t.Fatalf("snippet not injected after <body>: %s", out) + } +} + +func TestInjectIntoHTML_FallbackPrepend(t *testing.T) { + body := []byte("no tags here") + snippet := []byte("S") + out := string(injectIntoHTML(body, snippet)) + if !strings.HasPrefix(out, "Sno tags") { + t.Fatalf("snippet not prepended: %s", out) + } +} + +func TestInjectIntoHTML_CaseInsensitive(t *testing.T) { + body := []byte("<HTML><HEAD></HEAD></HTML>") + snippet := []byte("S") + out := string(injectIntoHTML(body, snippet)) + if !strings.Contains(out, "<HEAD>S") { + t.Fatalf("case-insensitive head match failed: %s", out) + } +} + +func TestBuildSnippet(t *testing.T) { + cfg := &config.Config{ + ServiceName: "my-frontend", + ResourceAttributes: "k8s.namespace.name=demo,k8s.pod.name=p1", + PropagateCorsUrls: "https://api.example.com,/.*backend.*/", + } + snippet, err := buildSnippet(cfg) + if err != nil { + t.Fatalf("buildSnippet error: %v", err) + } + s := string(snippet) + + if !strings.Contains(s, "window.__ODIGOS__=") { + t.Fatalf("missing config assignment: %s", s) + } + if !strings.Contains(s, `"serviceName":"my-frontend"`) { + t.Fatalf("missing service name: %s", s) + } + if !strings.Contains(s, `"tracesPath":"`+config.TracesPath+`"`) { + t.Fatalf("missing traces path: %s", s) + } + if !strings.Contains(s, "k8s.namespace.name") || !strings.Contains(s, "demo") { + t.Fatalf("missing resource attributes: %s", s) + } + if !strings.Contains(s, `src="`+config.AgentJsPath+`"`) { + t.Fatalf("missing agent script tag: %s", s) + } + // json.Marshal must escape '<' to avoid breaking out of the inline <script>. + if bytes.Contains(snippet, []byte("</script><script")) && strings.Count(s, "<script") != 2 { + t.Fatalf("unexpected extra script tags (possible injection break): %s", s) + } +} + +func TestParseResourceAttributes(t *testing.T) { + got := parseResourceAttributes(" a = 1 , b=2 , ,c= ") + if got["a"] != "1" || got["b"] != "2" || got["c"] != "" { + t.Fatalf("unexpected parse result: %#v", got) + } + if parseResourceAttributes("") != nil { + t.Fatalf("empty input should return nil") + } +} diff --git a/browser-proxy/internal/server/otlp.go b/browser-proxy/internal/server/otlp.go new file mode 100644 index 0000000000..1f730e65de --- /dev/null +++ b/browser-proxy/internal/server/otlp.go @@ -0,0 +1,83 @@ +package server + +import ( + "bytes" + "io" + "log" + "net/http" + "strings" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +// corsHeaders sets permissive CORS headers so the browser SDK (running on the application's own +// origin) can POST OTLP/HTTP telemetry to the sidecar. Since the sidecar shares the application's +// origin, this is effectively same-origin; the headers also cover the case where the page was +// loaded from a different origin (e.g. a CDN) and still posts back here. +func corsHeaders(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + origin = "*" + } + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Vary", "Origin") + h.Set("Access-Control-Allow-Methods", "POST, OPTIONS") + h.Set("Access-Control-Allow-Headers", "content-type, traceparent, tracestate, baggage") + h.Set("Access-Control-Max-Age", "86400") +} + +// handleOTLP forwards browser OTLP/HTTP telemetry to the node-local collector. The browser posts to +// a same-origin path under /__odigos/v1/ (e.g. /__odigos/v1/traces); the sidecar maps it to the +// collector's /v1/<signal> path and adds CORS headers to the response. +func (s *Server) handleOTLP(w http.ResponseWriter, r *http.Request) { + corsHeaders(w, r) + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Map /__odigos/v1/<signal> -> <collector>/v1/<signal>. + signalPath := strings.TrimPrefix(r.URL.Path, config.OtlpPathPrefix) + targetURL := s.cfg.OtlpHTTPEndpoint + "/v1/" + signalPath + + body, err := io.ReadAll(io.LimitReader(r.Body, maxOTLPBodyBytes)) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, targetURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, "failed to build upstream request", http.StatusInternalServerError) + return + } + // Preserve the payload framing so the collector can decode it (protobuf or json, possibly gzip). + copyHeader(req.Header, r.Header, "Content-Type") + copyHeader(req.Header, r.Header, "Content-Encoding") + + resp, err := s.otlpClient.Do(req) + if err != nil { + // The browser cannot reach the collector directly; swallow upstream errors as 502 but keep + // the page healthy (telemetry loss must never surface to end users). + log.Printf("browser-proxy: failed to forward OTLP to %s: %v", targetURL, err) + w.WriteHeader(http.StatusBadGateway) + return + } + defer resp.Body.Close() + + copyHeader(w.Header(), resp.Header, "Content-Type") + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, io.LimitReader(resp.Body, maxOTLPBodyBytes)) +} + +func copyHeader(dst, src http.Header, key string) { + if v := src.Get(key); v != "" { + dst.Set(key, v) + } +} diff --git a/browser-proxy/internal/server/server.go b/browser-proxy/internal/server/server.go new file mode 100644 index 0000000000..5eba489428 --- /dev/null +++ b/browser-proxy/internal/server/server.go @@ -0,0 +1,180 @@ +// Package server implements the odigos-browser-proxy HTTP server: a reverse proxy in front of a +// web-server container that injects the OpenTelemetry browser SDK <script> into HTML responses and +// proxies the browser's OTLP/HTTP telemetry to the node-local collector. +package server + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +const ( + // Upper bound on HTML bodies we will buffer to inject into. Larger responses are streamed + // through untouched (a multi-MB HTML document is almost certainly not a normal page). + maxHTMLInjectBytes = 8 << 20 // 8 MiB + // Upper bound on OTLP request/response bodies we relay. + maxOTLPBodyBytes = 16 << 20 // 16 MiB +) + +// Server is the browser-proxy HTTP server. +type Server struct { + cfg *config.Config + snippet []byte + proxy *httputil.ReverseProxy + otlpClient *http.Client +} + +// New builds a Server from the given configuration. +func New(cfg *config.Config) (*Server, error) { + upstreamURL, err := url.Parse(cfg.Upstream) + if err != nil { + return nil, fmt.Errorf("invalid upstream URL %q: %w", cfg.Upstream, err) + } + + snippet, err := buildSnippet(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build injection snippet: %w", err) + } + + s := &Server{ + cfg: cfg, + snippet: snippet, + otlpClient: &http.Client{Timeout: 30 * time.Second}, + } + + proxy := httputil.NewSingleHostReverseProxy(upstreamURL) + defaultDirector := proxy.Director + proxy.Director = func(req *http.Request) { + defaultDirector(req) + req.Host = upstreamURL.Host + // Only accept encodings we can decode for injection. This avoids receiving brotli/zstd + // HTML that we would otherwise have to skip. Non-HTML responses are passed through as-is. + req.Header.Set("Accept-Encoding", "gzip") + } + proxy.ModifyResponse = s.injectResponse + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("browser-proxy: upstream error for %s: %v", r.URL.Path, err) + w.WriteHeader(http.StatusBadGateway) + } + s.proxy = proxy + + return s, nil +} + +// Handler returns the root HTTP handler with routing for the reserved /__odigos/ paths and the +// reverse proxy fallthrough. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(config.AgentJsPath, s.handleAgentJS) + // All OTLP signals (traces/metrics/logs) under the reserved prefix. + mux.HandleFunc(config.OtlpPathPrefix, s.handleOTLP) + mux.HandleFunc("/", s.handleProxy) + return mux +} + +// Run starts the HTTP server and blocks. +func (s *Server) Run() error { + srv := &http.Server{ + Addr: s.cfg.ListenAddr, + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + log.Printf("browser-proxy: listening on %s, forwarding to %s (service=%q, collector=%s)", + s.cfg.ListenAddr, s.cfg.Upstream, s.cfg.ServiceName, s.cfg.OtlpHTTPEndpoint) + return srv.ListenAndServe() +} + +func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { + s.proxy.ServeHTTP(w, r) +} + +// handleAgentJS serves the browser SDK bundle from the mounted agents directory. +func (s *Server) handleAgentJS(w http.ResponseWriter, r *http.Request) { + path := filepath.Join(s.cfg.AgentDir, filepath.Base(s.cfg.AgentFile)) + f, err := os.Open(path) + if err != nil { + log.Printf("browser-proxy: agent bundle not found at %s: %v", path, err) + http.Error(w, "agent bundle not available", http.StatusNotFound) + return + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + http.Error(w, "agent bundle not available", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=300") + http.ServeContent(w, r, s.cfg.AgentFile, info.ModTime(), f) +} + +// injectResponse is the ReverseProxy ModifyResponse hook. It injects the SDK snippet into HTML +// responses, decompressing/recompressing gzip as needed, and leaves all other responses untouched. +func (s *Server) injectResponse(resp *http.Response) error { + if !isHTML(resp.Header.Get("Content-Type")) { + return nil + } + + encoding := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding"))) + if encoding != "" && encoding != "gzip" && encoding != "identity" { + // We forced Accept-Encoding: gzip upstream, so this is unexpected; skip injection to be safe. + return nil + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxHTMLInjectBytes+1)) + if err != nil { + return err + } + _ = resp.Body.Close() + + if len(raw) > maxHTMLInjectBytes { + // Too large to safely buffer/inject; pass through unchanged. + resp.Body = io.NopCloser(bytes.NewReader(raw)) + return nil + } + + decoded := raw + if encoding == "gzip" { + gr, gzErr := gzip.NewReader(bytes.NewReader(raw)) + if gzErr != nil { + // Not actually gzip / corrupt; pass through unchanged. + resp.Body = io.NopCloser(bytes.NewReader(raw)) + return nil + } + decoded, err = io.ReadAll(gr) + _ = gr.Close() + if err != nil { + resp.Body = io.NopCloser(bytes.NewReader(raw)) + return nil + } + } + + injected := injectIntoHTML(decoded, s.snippet) + + // Re-emit as identity to keep things simple; we drop the gzip encoding and let the response be + // served uncompressed. This is correct and avoids a recompression dependency. + resp.Header.Del("Content-Encoding") + resp.Body = io.NopCloser(bytes.NewReader(injected)) + resp.ContentLength = int64(len(injected)) + resp.Header.Set("Content-Length", strconv.Itoa(len(injected))) + return nil +} + +func isHTML(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "text/html") +} diff --git a/browser-proxy/internal/server/server_test.go b/browser-proxy/internal/server/server_test.go new file mode 100644 index 0000000000..71ba5aa0f2 --- /dev/null +++ b/browser-proxy/internal/server/server_test.go @@ -0,0 +1,146 @@ +package server + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +func newTestServer(t *testing.T, upstream string) *Server { + t.Helper() + s, err := New(&config.Config{ + ListenAddr: ":0", + Upstream: upstream, + AgentDir: "/var/odigos/browser", + AgentFile: "agent.js", + OtlpHTTPEndpoint: "http://collector:4318", + ServiceName: "test-frontend", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return s +} + +func TestProxyInjectsHTML(t *testing.T) { + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, "<html><head></head><body>app</body></html>") + })) + defer app.Close() + + s := newTestServer(t, app.URL) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + body := rec.Body.String() + if !strings.Contains(body, "window.__ODIGOS__=") { + t.Fatalf("expected injected config script, got: %s", body) + } + if !strings.Contains(body, `src="`+config.AgentJsPath+`"`) { + t.Fatalf("expected injected agent script, got: %s", body) + } + if rec.Header().Get("Content-Encoding") != "" { + t.Fatalf("expected content-encoding to be stripped after injection") + } +} + +func TestProxyInjectsGzippedHTML(t *testing.T) { + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + _, _ = gw.Write([]byte("<html><head></head><body>app</body></html>")) + _ = gw.Close() + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Encoding", "gzip") + _, _ = w.Write(buf.Bytes()) + })) + defer app.Close() + + s := newTestServer(t, app.URL) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + body := rec.Body.String() + if !strings.Contains(body, "window.__ODIGOS__=") { + t.Fatalf("expected injected config in gzipped html, got: %s", body) + } + if !strings.Contains(body, "app") { + t.Fatalf("expected original content preserved, got: %s", body) + } +} + +func TestProxyDoesNotInjectNonHTML(t *testing.T) { + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer app.Close() + + s := newTestServer(t, app.URL) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil)) + + if strings.Contains(rec.Body.String(), "__ODIGOS__") { + t.Fatalf("must not inject into non-HTML responses: %s", rec.Body.String()) + } +} + +func TestOTLPForwardingAndCORS(t *testing.T) { + var gotPath string + var gotBody []byte + collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer collector.Close() + + s, err := New(&config.Config{ + ListenAddr: ":0", + Upstream: "http://unused.local", + OtlpHTTPEndpoint: collector.URL, + AgentDir: "/var/odigos/browser", + AgentFile: "agent.js", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + // Preflight + pre := httptest.NewRecorder() + preReq := httptest.NewRequest(http.MethodOptions, config.TracesPath, nil) + preReq.Header.Set("Origin", "https://frontend.example.com") + s.Handler().ServeHTTP(pre, preReq) + if pre.Code != http.StatusNoContent { + t.Fatalf("preflight expected 204, got %d", pre.Code) + } + if pre.Header().Get("Access-Control-Allow-Origin") != "https://frontend.example.com" { + t.Fatalf("missing CORS origin on preflight: %v", pre.Header()) + } + + // Actual POST + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, config.TracesPath, strings.NewReader("payload")) + req.Header.Set("Content-Type", "application/x-protobuf") + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 from forwarded OTLP, got %d", rec.Code) + } + if gotPath != "/v1/traces" { + t.Fatalf("expected collector path /v1/traces, got %q", gotPath) + } + if string(gotBody) != "payload" { + t.Fatalf("expected forwarded body 'payload', got %q", string(gotBody)) + } + if rec.Header().Get("Access-Control-Allow-Origin") == "" { + t.Fatalf("expected CORS header on OTLP response") + } +} diff --git a/common/lang_detection.go b/common/lang_detection.go index c6572d851f..811f3c274f 100644 --- a/common/lang_detection.go +++ b/common/lang_detection.go @@ -13,7 +13,7 @@ type ProgramLanguageDetails struct { RuntimeVersion string } -// +kubebuilder:validation:Enum=java;python;go;dotnet;javascript;php;ruby;rust;cplusplus;mysql;nginx;redis;postgres;unknown;ignored;* +// +kubebuilder:validation:Enum=java;python;go;dotnet;javascript;browser;php;ruby;rust;cplusplus;mysql;nginx;redis;postgres;unknown;ignored;* type ProgrammingLanguage string const ( @@ -24,13 +24,18 @@ const ( GoProgrammingLanguage ProgrammingLanguage = "go" DotNetProgrammingLanguage ProgrammingLanguage = "dotnet" JavascriptProgrammingLanguage ProgrammingLanguage = "javascript" - PhpProgrammingLanguage ProgrammingLanguage = "php" - RubyProgrammingLanguage ProgrammingLanguage = "ruby" - RustProgrammingLanguage ProgrammingLanguage = "rust" - CPlusPlusProgrammingLanguage ProgrammingLanguage = "cplusplus" - CSharpProgrammingLanguage ProgrammingLanguage = "csharp" - SwiftProgrammingLanguage ProgrammingLanguage = "swift" - ElixirProgrammingLanguage ProgrammingLanguage = "elixir" + // BrowserProgrammingLanguage is JavaScript that runs in the end-user's browser (the OpenTelemetry + // Web SDK), as opposed to JavascriptProgrammingLanguage which is server-side Node.js. Unlike the + // other languages, the code is not executed by a process inside the pod, so it cannot be detected + // via /proc and is delivered to the browser by the odigos-browser-proxy sidecar. + BrowserProgrammingLanguage ProgrammingLanguage = "browser" + PhpProgrammingLanguage ProgrammingLanguage = "php" + RubyProgrammingLanguage ProgrammingLanguage = "ruby" + RustProgrammingLanguage ProgrammingLanguage = "rust" + CPlusPlusProgrammingLanguage ProgrammingLanguage = "cplusplus" + CSharpProgrammingLanguage ProgrammingLanguage = "csharp" + SwiftProgrammingLanguage ProgrammingLanguage = "swift" + ElixirProgrammingLanguage ProgrammingLanguage = "elixir" // This is an experimental feature, It is not a language // but in order to avoid huge refactoring we are adding it here for now MySQLProgrammingLanguage ProgrammingLanguage = "mysql" @@ -52,6 +57,8 @@ func MapOdigosToSemConv(odigosPrograminglang ProgrammingLanguage) string { switch odigosPrograminglang { case JavascriptProgrammingLanguage: return semconv.TelemetrySDKLanguageNodejs.Value.AsString() + case BrowserProgrammingLanguage: + return semconv.TelemetrySDKLanguageWebjs.Value.AsString() default: return string(odigosPrograminglang) } diff --git a/distros/distro/oteldistribution.go b/distros/distro/oteldistribution.go index 44d26d7eb2..37b53e1a1d 100644 --- a/distros/distro/oteldistribution.go +++ b/distros/distro/oteldistribution.go @@ -142,6 +142,23 @@ type Option struct { Value string `yaml:"value"` } +// BrowserSidecar marks a distribution whose telemetry SDK runs in the end-user's browser rather +// than in a process inside the pod. Such a distribution is not delivered by mounting agent files +// or setting runtime env vars on the application container. Instead, the instrumentor injects the +// odigos-browser-proxy sidecar (and a traffic-redirect init container) in front of the web server. +// The sidecar injects a <script> tag that loads the OpenTelemetry Web SDK bundle into served HTML +// responses, and proxies the browser's OTLP/HTTP telemetry back to the node-local collector. +type BrowserSidecar struct { + // The directory (under the agents dir) that contains the browser SDK bundle the sidecar serves. + // The special value {{ODIGOS_AGENTS_DIR}} is replaced with the actual agents directory at runtime + // (e.g. "{{ODIGOS_AGENTS_DIR}}/browser" -> "/var/odigos/browser" on k8s). + AgentDirectory string `yaml:"agentDirectory"` + + // The file name (within AgentDirectory) of the browser SDK bundle that the sidecar serves to the + // browser and references from the injected <script> tag (e.g. "agent.js"). + AgentFileName string `yaml:"agentFileName"` +} + type SpanMetrics struct { // if true, the agent supports span metrics. Supported bool `yaml:"supported,omitempty"` @@ -310,6 +327,11 @@ type OtelDistro struct { // Can be nil in case no runtime agent is required. RuntimeAgent *RuntimeAgent `yaml:"runtimeAgent,omitempty"` + // If set, this distribution targets the end-user's browser and is delivered by the + // odigos-browser-proxy sidecar (HTML <script> injection + same-origin OTLP proxy) instead of + // an in-pod runtime agent. When set, RuntimeAgent and runtime env-var injection are not used. + BrowserSidecar *BrowserSidecar `yaml:"browserSidecar,omitempty"` + // if true, the distro receives it's configuration as environment variables. // it means the distro does not support opamp and not configurable via ebpf. // these pods will require a restart to apply the new configuration. diff --git a/distros/oteldistributions.go b/distros/oteldistributions.go index 20700bf814..54831eeb95 100644 --- a/distros/oteldistributions.go +++ b/distros/oteldistributions.go @@ -48,6 +48,7 @@ func NewGetterFromFS(fs embed.FS) (*Getter, error) { func (c *communityDefaulter) GetDefaultDistroNames() map[common.ProgrammingLanguage]string { return map[common.ProgrammingLanguage]string{ common.JavascriptProgrammingLanguage: "nodejs-community", + common.BrowserProgrammingLanguage: "browser-community", common.PythonProgrammingLanguage: "python-community", common.DotNetProgrammingLanguage: "dotnet-community", common.JavaProgrammingLanguage: "java-community", diff --git a/distros/yamls/browser-community.yaml b/distros/yamls/browser-community.yaml new file mode 100644 index 0000000000..5174a32ee6 --- /dev/null +++ b/distros/yamls/browser-community.yaml @@ -0,0 +1,26 @@ +apiVersion: internal.odigos.io/v1beta1 +kind: OtelDistribution +metadata: + name: browser-community +spec: + name: browser-community + language: browser + runtimeEnvironments: + - name: browser + supportedVersions: '*' + displayName: 'Browser Community Native Instrumentation' + description: | + This distribution instruments front-end web applications using the OpenTelemetry Web SDK + and instrumentation libraries from the OpenTelemetry community. + + Unlike server-side distributions, the telemetry SDK runs in the end-user's browser. Odigos + does not mount agent files into the application container or set runtime environment variables. + Instead, the odigos-browser-proxy sidecar is injected in front of the web server: it injects a + <script> tag that loads the OpenTelemetry Web SDK bundle into served HTML responses, and proxies + the browser's OTLP/HTTP telemetry (same-origin) back to the node-local collector. + browserSidecar: + agentDirectory: '{{ODIGOS_AGENTS_DIR}}/browser' + agentFileName: 'agent.js' + traces: + headersCollection: + supported: true diff --git a/docs/docs.json b/docs/docs.json index 54c46da23a..63f7ec208a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -152,6 +152,13 @@ "oss/instrumentations/dotnet/enrichment" ] }, + { + "group": "Browser", + "icon": "globe", + "pages": [ + "oss/instrumentations/browser/native" + ] + }, { "group": "Advanced", "icon": "gear", diff --git a/docs/oss/instrumentations/browser/native.mdx b/docs/oss/instrumentations/browser/native.mdx new file mode 100644 index 0000000000..140a7262c3 --- /dev/null +++ b/docs/oss/instrumentations/browser/native.mdx @@ -0,0 +1,82 @@ +--- +title: "Browser (Web) Instrumentation" +sidebarTitle: "Browser" +icon: "globe" +--- + +Odigos can instrument **front-end web applications** with the [OpenTelemetry Web SDK](https://opentelemetry.io/docs/languages/js/getting-started/browser/), +capturing browser-side telemetry such as page loads, `fetch`/`XHR` requests, and user interactions. + +<Note> + Browser instrumentation is fundamentally different from Odigos' server-side language agents. The + telemetry SDK runs in the **end user's browser**, not in a process inside the pod, so it cannot be + auto-detected from `/proc` and must be enabled explicitly (see [Enabling](#enabling) below). +</Note> + +## How it works + +Because the OpenTelemetry Web SDK runs in the browser, Odigos does not mount agent files into the +application container or set runtime environment variables. Instead it injects a small sidecar, +`odigos-browser-proxy`, in front of the web server container: + +```mermaid +flowchart TD + User["End-user browser"] -->|"GET / (HTML)"| SC["odigos-browser-proxy sidecar"] + SC -->|"forward"| App["web server container<br/>(nginx / serve / ...)"] + App -->|"HTML response"| SC + SC -->|"inject <script> + recompress"| User + User -->|"GET /__odigos/agent.js"| SC + User -->|"POST /__odigos/v1/traces (OTLP)"| SC + SC -->|"forward + CORS"| NC["node-local collector :4318"] +``` + +The sidecar: + +1. **Injects** a `<script>` tag that loads the OpenTelemetry Web SDK bundle into `text/html` responses + (gzip-aware), configured at runtime via an injected `window.__ODIGOS__` global. +2. **Serves** the SDK bundle at the same-origin path `/__odigos/agent.js`. +3. **Receives** the browser's OTLP/HTTP telemetry at the same-origin path `/__odigos/v1/traces` and + **forwards** it to the node-local Odigos collector. Because telemetry is sent same-origin, no CORS + configuration or public collector endpoint is required. + +An init container installs an `iptables` rule (Istio-style) that transparently redirects the +application's inbound traffic to the sidecar, so the Kubernetes `Service` does not need to change. + +## Enabling + +Front-end workloads cannot be reliably auto-detected, so browser instrumentation is **opt-in** per +container. Create (or edit) a `Source` for the workload and set a container override that selects the +`browser-community` distribution on the serving container: + +```yaml +apiVersion: odigos.io/v1alpha1 +kind: Source +metadata: + name: my-frontend-source + namespace: my-namespace +spec: + workload: + name: my-frontend + namespace: my-namespace + kind: Deployment + containerOverrides: + - containerName: my-frontend # the container serving the HTML + otelDistroName: browser-community +``` + +<Tip> + If your front-end is served by a process Odigos would otherwise auto-instrument as server-side code + (for example a Node.js static-file server), the `browser-community` override takes precedence and the + server-side agent is not applied to that container. +</Tip> + +When the workload's pods are (re)created, Odigos injects the `odigos-browser-proxy` sidecar and the +traffic-redirect init container. Open the application in a browser and confirm that browser traces +arrive at your configured destination. + +## Requirements & notes + +- The serving container must expose a TCP `containerPort`; the sidecar fronts that port. +- The redirect init container requires the `NET_ADMIN` capability. Namespaces enforcing a restrictive + Pod Security Standard may need an exception for the instrumented workload. +- Only `text/html` responses are rewritten; all other responses (assets, APIs) pass through unchanged. diff --git a/helm/odigos/templates/crds/odigos.io_actions.yaml b/helm/odigos/templates/crds/odigos.io_actions.yaml index 744f96e518..ce9fc733a7 100644 --- a/helm/odigos/templates/crds/odigos.io_actions.yaml +++ b/helm/odigos/templates/crds/odigos.io_actions.yaml @@ -304,6 +304,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -376,6 +377,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -468,6 +470,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/crds/odigos.io_instrumentationconfigs.yaml b/helm/odigos/templates/crds/odigos.io_instrumentationconfigs.yaml index 2df0549cda..f0b03dfac3 100644 --- a/helm/odigos/templates/crds/odigos.io_instrumentationconfigs.yaml +++ b/helm/odigos/templates/crds/odigos.io_instrumentationconfigs.yaml @@ -689,6 +689,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -727,6 +728,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -862,6 +864,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -1218,6 +1221,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -1838,6 +1842,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/crds/odigos.io_instrumentationrules.yaml b/helm/odigos/templates/crds/odigos.io_instrumentationrules.yaml index 8a2b04d4f0..2709438daf 100644 --- a/helm/odigos/templates/crds/odigos.io_instrumentationrules.yaml +++ b/helm/odigos/templates/crds/odigos.io_instrumentationrules.yaml @@ -223,6 +223,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -380,6 +381,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -447,6 +449,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -484,6 +487,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/crds/odigos.io_samplings.yaml b/helm/odigos/templates/crds/odigos.io_samplings.yaml index 69938a5e30..4b63118ce3 100644 --- a/helm/odigos/templates/crds/odigos.io_samplings.yaml +++ b/helm/odigos/templates/crds/odigos.io_samplings.yaml @@ -131,6 +131,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -279,6 +280,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -478,6 +480,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/crds/odigos.io_sources.yaml b/helm/odigos/templates/crds/odigos.io_sources.yaml index 8b54d0217f..8a7633f92c 100644 --- a/helm/odigos/templates/crds/odigos.io_sources.yaml +++ b/helm/odigos/templates/crds/odigos.io_sources.yaml @@ -120,6 +120,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/instrumentor/deployment.yaml b/helm/odigos/templates/instrumentor/deployment.yaml index 856d534752..f29f24563a 100644 --- a/helm/odigos/templates/instrumentor/deployment.yaml +++ b/helm/odigos/templates/instrumentor/deployment.yaml @@ -108,6 +108,8 @@ spec: {{- else }} value: {{ template "utils.imageName" (dict "Values" .Values "Component" "agents" "Tag" $imageTag) }} {{- end }} + - name: ODIGOS_BROWSER_PROXY_IMAGE + value: {{ template "utils.imageName" (dict "Values" .Values "Component" "browser-proxy" "Tag" $imageTag) }} - name: GOMEMLIMIT value: {{ include "odigos.gomemlimitFromResources" (dict "Resources" .Values.instrumentor.resources) }} volumeMounts: diff --git a/instrumentor/controllers/agentenabled/browser_proxy.go b/instrumentor/controllers/agentenabled/browser_proxy.go new file mode 100644 index 0000000000..e5f6585e3c --- /dev/null +++ b/instrumentor/controllers/agentenabled/browser_proxy.go @@ -0,0 +1,144 @@ +package agentenabled + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + + "github.com/odigos-io/odigos/api/k8sconsts" + "github.com/odigos-io/odigos/common" + "github.com/odigos-io/odigos/common/consts" + "github.com/odigos-io/odigos/distros/distro" + "github.com/odigos-io/odigos/instrumentor/controllers/agentenabled/podswebhook" + "github.com/odigos-io/odigos/k8sutils/pkg/service" +) + +// injectBrowserProxy builds the odigos-browser-proxy sidecar and the iptables init container for a +// browser-instrumented web server container. Browser instrumentation does not run inside the pod, +// so the application container is left untouched: no env vars and no agent mounts are added to it. +// Instead the sidecar is placed in front of it (via the init container's iptables redirect) to +// inject the OpenTelemetry browser SDK <script> into served HTML and to proxy the browser's OTLP +// telemetry to the node-local collector. +// +// Returns the sidecar container, the init container, the agent directories that need to be copied +// (for the init-container mount method), and whether a pod volume mount is required. If the +// application container exposes no TCP port, injection is skipped (nil sidecar) since there is +// nothing to redirect. +func (p *PodsWebhook) injectBrowserProxy( + logger logr.Logger, + appContainer *corev1.Container, + namespace string, + serviceName string, + config common.OdigosConfiguration, + distroMetadata *distro.OtelDistro, +) (*corev1.Container, *corev1.Container, map[string]struct{}, bool, error) { + appPort := firstTCPContainerPort(appContainer) + if appPort == 0 { + logger.Info("browser instrumentation: container has no TCP containerPort, skipping browser-proxy injection", + "container", appContainer.Name) + return nil, nil, nil, false, nil + } + + mountMethod := common.K8sVirtualDeviceMountMethod + if config.MountMethod != nil { + mountMethod = *config.MountMethod + } + + proxyImage := getBrowserProxyImage(config) + agentDirResolved := strings.ReplaceAll(distroMetadata.BrowserSidecar.AgentDirectory, distro.AgentPlaceholderDirectory, k8sconsts.OdigosAgentsDirectory) + + falsePtr := false + truePtr := true + runAsProxy := k8sconsts.BrowserProxyRunAsUser + runAsRoot := int64(0) + + // The browser SDK exports to the sidecar over the application's own origin; the sidecar then + // forwards to the node-local collector reachable at $(NODE_IP):4318. + otlpEndpoint := service.LocalTrafficOTLPHttpDataCollectionEndpoint("$(NODE_IP)") + + sidecar := corev1.Container{ + Name: k8sconsts.BrowserProxyContainerName, + Image: proxyImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Ports: []corev1.ContainerPort{ + {ContainerPort: int32(k8sconsts.BrowserProxyListenPort)}, + }, + Env: []corev1.EnvVar{ + // NODE_IP must come first so the OTLP endpoint env var can reference $(NODE_IP). + { + Name: "NODE_IP", + ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "status.hostIP"}}, + }, + {Name: k8sconsts.BrowserProxyUpstreamEnvVar, Value: fmt.Sprintf("http://127.0.0.1:%d", appPort)}, + {Name: k8sconsts.BrowserProxyListenAddrEnvVar, Value: fmt.Sprintf(":%d", k8sconsts.BrowserProxyListenPort)}, + {Name: k8sconsts.BrowserProxyAgentDirEnvVar, Value: agentDirResolved}, + {Name: k8sconsts.BrowserProxyAgentFileEnvVar, Value: distroMetadata.BrowserSidecar.AgentFileName}, + {Name: k8sconsts.BrowserProxyOtlpHttpEndpointEnvVar, Value: otlpEndpoint}, + {Name: k8sconsts.BrowserProxyServiceNameEnvVar, Value: serviceName}, + {Name: k8sconsts.BrowserProxyResourceAttributesEnvVar, Value: fmt.Sprintf("k8s.namespace.name=%s", namespace)}, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &runAsProxy, + AllowPrivilegeEscalation: &falsePtr, + Privileged: &falsePtr, + }, + } + + // Make the browser SDK bundle available to the sidecar, using the configured mount method. + dirsToCopy := make(map[string]struct{}) + volumeMounted := false + switch mountMethod { + case common.K8sHostPathMountMethod, common.K8sInitContainerMountMethod, common.K8sCsiDriverMountMethod: + podswebhook.MountDirectory(&sidecar, distroMetadata.BrowserSidecar.AgentDirectory) + dirsToCopy[distroMetadata.BrowserSidecar.AgentDirectory] = struct{}{} + volumeMounted = true + case common.K8sVirtualDeviceMountMethod: + podswebhook.InjectDeviceToContainer(&sidecar, k8sconsts.OdigosGenericDeviceName) + } + + // The init container installs the iptables redirect (inbound app port -> sidecar) before the + // application starts. It needs root + CAP_NET_ADMIN; the sidecar's own traffic (UID + // BrowserProxyRunAsUser) is excluded from the redirect so it can reach the app on loopback. + initContainer := corev1.Container{ + Name: k8sconsts.BrowserProxyInitContainerName, + Image: proxyImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Args: []string{"init"}, + Env: []corev1.EnvVar{ + {Name: k8sconsts.BrowserProxyAppPortEnvVar, Value: strconv.Itoa(int(appPort))}, + {Name: k8sconsts.BrowserProxyUidEnvVar, Value: strconv.FormatInt(k8sconsts.BrowserProxyRunAsUser, 10)}, + {Name: k8sconsts.BrowserProxyListenAddrEnvVar, Value: fmt.Sprintf(":%d", k8sconsts.BrowserProxyListenPort)}, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &runAsRoot, + AllowPrivilegeEscalation: &truePtr, + Capabilities: &corev1.Capabilities{ + Add: []corev1.Capability{"NET_ADMIN"}, + }, + }, + } + + return &sidecar, &initContainer, dirsToCopy, volumeMounted, nil +} + +func firstTCPContainerPort(container *corev1.Container) int32 { + for _, port := range container.Ports { + if port.ContainerPort > 0 && (port.Protocol == "" || port.Protocol == corev1.ProtocolTCP) { + return port.ContainerPort + } + } + return 0 +} + +func getBrowserProxyImage(config common.OdigosConfiguration) string { + // In the installation/upgrade we set the image as env var, so prefer it when present. + if img, ok := os.LookupEnv(k8sconsts.OdigosBrowserProxyEnvVarName); ok { + return img + } + imageVersion := os.Getenv(consts.OdigosVersionEnvVarName) + return config.ImagePrefix + "/" + k8sconsts.OdigosBrowserProxyImage + ":" + imageVersion +} diff --git a/instrumentor/controllers/agentenabled/distroresolver/distroresolver.go b/instrumentor/controllers/agentenabled/distroresolver/distroresolver.go index 87980c2385..704e6006bd 100644 --- a/instrumentor/controllers/agentenabled/distroresolver/distroresolver.go +++ b/instrumentor/controllers/agentenabled/distroresolver/distroresolver.go @@ -25,6 +25,14 @@ func resolveDistroByOverride(overwriteDistroName string, distroGetter *distros.G return distro, nil } + // Browser (web) distros are delivered by a sidecar in front of whatever web server runs in the + // pod. The frontend nature of a workload cannot be reliably auto-detected from the in-pod + // process, so browser instrumentation is opt-in via this override and must work regardless of + // the detected in-pod language (nginx, javascript/node static server, unknown, ...). + if distro.BrowserSidecar != nil { + return distro, nil + } + // verify the distro matches the language, since it might be overridden by the container override. if distro.Language != containerLanguage { return nil, &odigosv1.AgentDisabledInfo{ @@ -127,7 +135,10 @@ func ResolveDistroForContainer( } // check unknown language first. if language is not supported, we can skip the rest of the checks. - if runtimeDetails.Language == common.UnknownProgrammingLanguage { + // An explicit distro override is honored even when the language is unknown (e.g. opting a + // static-file web server into browser instrumentation); resolveDistroByOverride validates it. + hasDistroOverride := containerOverride != nil && containerOverride.OtelDistroName != nil + if runtimeDetails.Language == common.UnknownProgrammingLanguage && !hasDistroOverride { return nil, &odigosv1.AgentDisabledInfo{ AgentEnabledReason: odigosv1.AgentEnabledReasonUnsupportedProgrammingLanguage, AgentEnabledMessage: "unknown programming language", diff --git a/instrumentor/controllers/agentenabled/distroresolver/distroresolver_test.go b/instrumentor/controllers/agentenabled/distroresolver/distroresolver_test.go index b415fa0d86..5b659bdc6a 100644 --- a/instrumentor/controllers/agentenabled/distroresolver/distroresolver_test.go +++ b/instrumentor/controllers/agentenabled/distroresolver/distroresolver_test.go @@ -107,6 +107,53 @@ func TestResolveDistroForContainer_prereleaseRuntimeVersionAccepted(t *testing.T require.Equal(t, "golang-community", d.Name) } +func TestResolveDistroForContainer_browserOverrideAcceptsMismatchedContainerLanguage(t *testing.T) { + g := mustNewCommunityGetter(t) + overrideName := "browser-community" + config := &common.OdigosConfiguration{} + // A frontend served by a Node static server is detected as server-side javascript; the explicit + // browser override must still win since the frontend nature cannot be auto-detected. + rt := &odigosv1.RuntimeDetailsByContainer{Language: common.JavascriptProgrammingLanguage} + dpl := map[common.ProgrammingLanguage]string{common.JavascriptProgrammingLanguage: "nodejs-community"} + co := &odigosv1.ContainerOverride{OtelDistroName: &overrideName} + + d, info := ResolveDistroForContainer(config, rt, dpl, g, co, "frontend") + require.Nil(t, info) + require.NotNil(t, d) + require.Equal(t, "browser-community", d.Name) + require.NotNil(t, d.BrowserSidecar, "browser-community must carry a browserSidecar marker") +} + +func TestResolveDistroForContainer_browserOverrideAcceptsUnknownLanguage(t *testing.T) { + g := mustNewCommunityGetter(t) + overrideName := "browser-community" + config := &common.OdigosConfiguration{} + // A plain static-file server may not be detected at all; an explicit browser override must + // still be honored (the unknown-language early return is skipped when an override is present). + rt := &odigosv1.RuntimeDetailsByContainer{Language: common.UnknownProgrammingLanguage} + dpl := map[common.ProgrammingLanguage]string{} + co := &odigosv1.ContainerOverride{OtelDistroName: &overrideName} + + d, info := ResolveDistroForContainer(config, rt, dpl, g, co, "frontend") + require.Nil(t, info) + require.NotNil(t, d) + require.Equal(t, "browser-community", d.Name) +} + +func TestResolveDistroForContainer_browserByLanguageOverride(t *testing.T) { + g := mustNewCommunityGetter(t) + config := &common.OdigosConfiguration{} + // Opting in by setting the runtime language to browser (via containerOverride RuntimeInfo) + // resolves to the default browser distro without an explicit distro name. + rt := &odigosv1.RuntimeDetailsByContainer{Language: common.BrowserProgrammingLanguage} + dpl := map[common.ProgrammingLanguage]string{common.BrowserProgrammingLanguage: "browser-community"} + + d, info := ResolveDistroForContainer(config, rt, dpl, g, nil, "frontend") + require.Nil(t, info) + require.NotNil(t, d) + require.Equal(t, "browser-community", d.Name) +} + func TestResolveDistroForContainer_nonWildcardEnforcesRuntimeSemver(t *testing.T) { g := mustNewCommunityGetter(t) config := &common.OdigosConfiguration{} diff --git a/instrumentor/controllers/agentenabled/pods_webhook.go b/instrumentor/controllers/agentenabled/pods_webhook.go index 35dee6ce14..a08d91bab7 100644 --- a/instrumentor/controllers/agentenabled/pods_webhook.go +++ b/instrumentor/controllers/agentenabled/pods_webhook.go @@ -178,6 +178,12 @@ func (p *PodsWebhook) injectOdigos(ctx context.Context, pod *corev1.Pod, req adm volumeMounted := false waspSupported := false + // Browser-instrumented containers get a sidecar (+iptables init container) injected instead of + // in-container env/agent mounts. We collect them here and append after the loop to avoid + // mutating pod.Spec.Containers while ranging over it. + var browserSidecars []corev1.Container + var browserInitContainers []corev1.Container + dirsToCopy := make(map[string]struct{}) for i := range pod.Spec.Containers { podContainerSpec := &pod.Spec.Containers[i] @@ -197,6 +203,25 @@ func (p *PodsWebhook) injectOdigos(ctx context.Context, pod *corev1.Pod, req adm return ErrUnknownDistroName } + // Browser distros are delivered by the odigos-browser-proxy sidecar; the application + // container is not modified (no env vars, no agent mount). + if distroMetadata.BrowserSidecar != nil { + sidecar, initContainer, browserDirsToCopy, browserVolumeMounted, berr := p.injectBrowserProxy( + logger.Logr(), podContainerSpec, pw.Namespace, serviceName, odigosConfiguration, distroMetadata) + if berr != nil { + return berr + } + if sidecar != nil { + browserSidecars = append(browserSidecars, *sidecar) + if initContainer != nil { + browserInitContainers = append(browserInitContainers, *initContainer) + } + volumeMounted = volumeMounted || browserVolumeMounted + dirsToCopy = mergeMaps(dirsToCopy, browserDirsToCopy) + } + continue + } + containerVolumeMounted, containerDirsToCopy, err := p.injectOdigosToContainer( containerConfig, podContainerSpec, &ic, *pw, serviceName, odigosConfiguration, distroMetadata, pod.OwnerReferences) if err != nil { @@ -211,6 +236,18 @@ func (p *PodsWebhook) injectOdigos(ctx context.Context, pod *corev1.Pod, req adm dirsToCopy = mergeMaps(dirsToCopy, containerDirsToCopy) } + // Append the collected browser-proxy sidecar(s) and iptables init container(s). + for i := range browserInitContainers { + if !containerNameExists(pod.Spec.InitContainers, browserInitContainers[i].Name) { + pod.Spec.InitContainers = append(pod.Spec.InitContainers, browserInitContainers[i]) + } + } + for i := range browserSidecars { + if !containerNameExists(pod.Spec.Containers, browserSidecars[i].Name) { + pod.Spec.Containers = append(pod.Spec.Containers, browserSidecars[i]) + } + } + if mountMethod == common.K8sHostPathMountMethod && volumeMounted { // only mount the volume if at least one container has a volume to mount podswebhook.MountPodVolumeToHostPath(pod) @@ -261,6 +298,15 @@ func mergeMaps[T any](a, b map[string]T) map[string]T { return a } +func containerNameExists(containers []corev1.Container, name string) bool { + for i := range containers { + if containers[i].Name == name { + return true + } + } + return false +} + func (p *PodsWebhook) podWorkload(pod *corev1.Pod, req admission.Request) (*k8sconsts.PodWorkload, error) { pw, err := workload.PodWorkloadObject(pod) if err != nil { @@ -308,6 +354,12 @@ func (p *PodsWebhook) injectOdigosInstrumentation(ctx context.Context, pod *core continue } + // Browser distros do not run an in-pod agent, so there are no agent env vars to inject into + // the application container; the odigos-browser-proxy sidecar handles delivery instead. + if otelDistro.BrowserSidecar != nil { + continue + } + err := webhookenvinjector.InjectOdigosAgentEnvVars(ctx, logger.Logr(), container, otelDistro, runtimeDetails, config) if err != nil { return err diff --git a/odiglet/Dockerfile b/odiglet/Dockerfile index 8f18e45083..9aab4c41f0 100644 --- a/odiglet/Dockerfile +++ b/odiglet/Dockerfile @@ -59,6 +59,11 @@ COPY --from=public.ecr.aws/odigos/agents/php-community:v0.3.3 /instrumentations/ # ruby-community COPY --from=public.ecr.aws/odigos/agents/ruby-community:v0.0.8 /instrumentations/ruby /instrumentations/ruby +# browser-community +# The browser SDK bundle (agent.js) is served to end-user browsers by the odigos-browser-proxy +# sidecar, which reads it from /var/odigos/browser on the node. +COPY --from=public.ecr.aws/odigos/agents/browser-community:v0.1.0 /instrumentations/browser /instrumentations/browser + # loader ARG TARGETARCH ARG ODIGOS_LOADER_VERSION=v0.0.8 diff --git a/odiglet/agent-deps.mk b/odiglet/agent-deps.mk index c9ba4dc665..ff9f5badff 100644 --- a/odiglet/agent-deps.mk +++ b/odiglet/agent-deps.mk @@ -21,6 +21,8 @@ upgrade-agent: $(MAKE) -f $(MK) upgrade-image-agent-version AGENT_DISTRO=ruby-community AGENT_VERSION=$(AGENT_VERSION) ;; \ nodejs|nodejs-community) \ $(MAKE) -f $(MK) upgrade-image-agent-version AGENT_DISTRO=nodejs-community AGENT_VERSION=$(AGENT_VERSION) ;; \ + browser|browser-community) \ + $(MAKE) -f $(MK) upgrade-image-agent-version AGENT_DISTRO=browser-community AGENT_VERSION=$(AGENT_VERSION) ;; \ python|python-community) \ $(MAKE) -f $(MK) upgrade-python-community-version AGENT_VERSION=$(AGENT_VERSION) ;; \ *) \ diff --git a/odiglet/debug.Dockerfile b/odiglet/debug.Dockerfile index e841888a7c..b453b12d9b 100644 --- a/odiglet/debug.Dockerfile +++ b/odiglet/debug.Dockerfile @@ -81,6 +81,9 @@ COPY --from=public.ecr.aws/odigos/agents/php-community:v0.3.3 /instrumentations/ # ruby-community COPY --from=public.ecr.aws/odigos/agents/ruby-community:v0.0.8 /instrumentations/ruby /instrumentations/ruby +# browser-community +COPY --from=public.ecr.aws/odigos/agents/browser-community:v0.1.0 /instrumentations/browser /instrumentations/browser + # loader ARG ODIGOS_LOADER_VERSION=v0.0.8 RUN wget --directory-prefix=loader https://storage.googleapis.com/odigos-loader/$ODIGOS_LOADER_VERSION/$TARGETARCH/loader.so diff --git a/procdiscovery/pkg/inspectors/browser/browser.go b/procdiscovery/pkg/inspectors/browser/browser.go new file mode 100644 index 0000000000..8af4ee475d --- /dev/null +++ b/procdiscovery/pkg/inspectors/browser/browser.go @@ -0,0 +1,48 @@ +package browser + +import ( + "path/filepath" + "slices" + + "github.com/odigos-io/odigos/common" + "github.com/odigos-io/odigos/procdiscovery/pkg/process" +) + +// BrowserInspector heuristically identifies a container that serves a front-end web application +// (static assets / a single-page app) by recognizing common static web servers. +// +// IMPORTANT: this inspector is intentionally NOT registered in the active inspector registry +// (procdiscovery/pkg/inspectors/langdetect.go). Browser instrumentation cannot be reliably +// auto-detected from the in-pod process: a static server such as nginx or `serve` is +// indistinguishable from a backend that happens to use the same server, and a Node-based static +// server would otherwise be auto-instrumented as server-side JavaScript. Enabling this inspector by +// default would risk double-instrumenting or mis-instrumenting workloads. +// +// Browser instrumentation is therefore opt-in (see the Source containerOverride mechanism). This +// inspector is kept here as ready scaffolding for a future, explicitly gated auto-detection +// behavior, and to centralize the heuristic in one place. +type BrowserInspector struct{} + +// staticServerProcessNames are executables commonly used to serve front-end assets. The list is +// deliberately conservative. +var staticServerProcessNames = []string{ + "serve", // npm "serve" + "http-server", // npm "http-server" + "caddy", // caddy file server +} + +func (b *BrowserInspector) QuickScan(pcx *process.ProcessContext) (common.ProgrammingLanguage, bool) { + baseExe := filepath.Base(pcx.Details.ExePath) + if slices.Contains(staticServerProcessNames, baseExe) { + return common.BrowserProgrammingLanguage, true + } + return "", false +} + +func (b *BrowserInspector) DeepScan(pcx *process.ProcessContext) (common.ProgrammingLanguage, bool) { + return "", false +} + +func (b *BrowserInspector) GetRuntimeVersion(pcx *process.ProcessContext) string { + return "" +} diff --git a/tests/e2e/browser-instrumentation/01-frontend.yaml b/tests/e2e/browser-instrumentation/01-frontend.yaml new file mode 100644 index 0000000000..7202334c6d --- /dev/null +++ b/tests/e2e/browser-instrumentation/01-frontend.yaml @@ -0,0 +1,62 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: browser-demo +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: frontend-html + namespace: browser-demo +data: + index.html: | + <!doctype html> + <html> + <head> + <title>Odigos Browser Demo + + +

Odigos Browser Instrumentation Demo

+ + + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: browser-demo +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: nginx:1.27-alpine + ports: + - containerPort: 80 + volumeMounts: + - name: html + mountPath: /usr/share/nginx/html + volumes: + - name: html + configMap: + name: frontend-html +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: browser-demo +spec: + selector: + app: frontend + ports: + - port: 80 + targetPort: 80 diff --git a/tests/e2e/browser-instrumentation/02-source.yaml b/tests/e2e/browser-instrumentation/02-source.yaml new file mode 100644 index 0000000000..74844893d2 --- /dev/null +++ b/tests/e2e/browser-instrumentation/02-source.yaml @@ -0,0 +1,15 @@ +apiVersion: odigos.io/v1alpha1 +kind: Source +metadata: + name: frontend-source + namespace: browser-demo +spec: + workload: + name: frontend + namespace: browser-demo + kind: Deployment + # Browser instrumentation is opt-in: explicitly select the browser distribution on the serving + # container. Odigos then injects the odigos-browser-proxy sidecar instead of an in-pod agent. + containerOverrides: + - containerName: frontend + otelDistroName: browser-community diff --git a/tests/e2e/browser-instrumentation/03-assert-sidecar-injected.yaml b/tests/e2e/browser-instrumentation/03-assert-sidecar-injected.yaml new file mode 100644 index 0000000000..a9956f4db0 --- /dev/null +++ b/tests/e2e/browser-instrumentation/03-assert-sidecar-injected.yaml @@ -0,0 +1,24 @@ +# Asserts that the webhook injected the browser-proxy sidecar and the iptables redirect init +# container into the frontend pod, and that the application container itself was left untouched +# (no odigos agent env vars / device). +apiVersion: v1 +kind: Pod +metadata: + namespace: browser-demo + labels: + app: frontend +spec: + initContainers: + - name: odigos-browser-proxy-init + args: + - init + securityContext: + capabilities: + add: + - NET_ADMIN + containers: + - name: odigos-browser-proxy + ports: + - containerPort: 15001 + securityContext: + runAsUser: 1337 diff --git a/tests/e2e/browser-instrumentation/README.md b/tests/e2e/browser-instrumentation/README.md new file mode 100644 index 0000000000..30b62f20b2 --- /dev/null +++ b/tests/e2e/browser-instrumentation/README.md @@ -0,0 +1,33 @@ +# Browser instrumentation demo / e2e + +A self-contained demo that opts an nginx-served front-end into Odigos browser instrumentation and +verifies that the `odigos-browser-proxy` sidecar (and its iptables-redirect init container) is +injected into the workload's pods. + +## Why it isn't in the default e2e matrix + +This scenario needs the `odigos-browser-proxy` image present in the cluster, which the default e2e +job does not build/load. Keep it out of `.github/workflows/e2e.yaml`'s `test-scenario` matrix unless +that image is wired into the e2e build/load step. + +## Run it locally (kind) + +```bash +# from the repo root, with a kind cluster + Odigos installed +make build-browser-proxy load-to-kind-browser-proxy TAG=e2e-test + +# build + load the browser agent bundle into the node so the sidecar can serve it +# (from the opentelemetry-browser repo): make deploy-dev + +# run the chainsaw scenario +cd tests/e2e/browser-instrumentation +chainsaw test . +``` + +## Manual verification of trace export + +1. Port-forward the front-end service: `kubectl -n browser-demo port-forward svc/frontend 8080:80`. +2. Open `http://localhost:8080` in a browser and click **Ping**. +3. View source / network tab: the HTML contains an injected `window.__ODIGOS__` config script and a + ` safely (it escapes '<' as \u003c by default), - // preventing the inline JSON from prematurely closing the script tag. + b.WriteString("window.__ODIGOS__=") b.Write(configJSON) - b.WriteString(";") - b.WriteString(``) + b.WriteString(";") return b.Bytes(), nil } +// buildSnippet renders CSP-safe external ") +} + +// extractCSPNonce returns the first CSP nonce value from a Content-Security-Policy header, if any. +func extractCSPNonce(cspHeader string) string { + m := cspNonceRE.FindStringSubmatch(cspHeader) + if len(m) < 2 { + return "" + } + return m[1] +} + // parseResourceAttributes parses an OTEL_RESOURCE_ATTRIBUTES-style string ("k1=v1,k2=v2"). func parseResourceAttributes(raw string) map[string]string { raw = strings.TrimSpace(raw) @@ -130,3 +164,12 @@ func spliceAt(body, snippet []byte, idx int) []byte { out = append(out, body[idx:]...) return out } + +// snippetForResponse picks a nonce-aware injection snippet for this HTML response. +func (s *Server) snippetForResponse(cspHeader string) []byte { + nonce := extractCSPNonce(cspHeader) + if nonce == "" { + return s.snippet + } + return buildSnippet(nonce) +} diff --git a/browser-proxy/internal/server/inject_test.go b/browser-proxy/internal/server/inject_test.go index 7b9ae0ced9..78fb5e0d5c 100644 --- a/browser-proxy/internal/server/inject_test.go +++ b/browser-proxy/internal/server/inject_test.go @@ -1,7 +1,6 @@ package server import ( - "bytes" "strings" "testing" @@ -54,19 +53,43 @@ func TestInjectIntoHTML_CaseInsensitive(t *testing.T) { } } -func TestBuildSnippet(t *testing.T) { +func TestBuildSnippetExternalOnly(t *testing.T) { + s := string(buildSnippet("")) + if strings.Contains(s, "window.__ODIGOS__") { + t.Fatalf("snippet must not contain inline config: %s", s) + } + if !strings.Contains(s, `src="`+config.ConfigJsPath+`"`) { + t.Fatalf("missing config.js script tag: %s", s) + } + if !strings.Contains(s, `src="`+config.AgentJsPath+`"`) { + t.Fatalf("missing agent script tag: %s", s) + } + if strings.Count(s, ". - if bytes.Contains(snippet, []byte(" path and adds CORS headers to the response. +// handleOTLP authenticates, rate-limits, and forwards browser OTLP/HTTP to the node-local collector. func (s *Server) handleOTLP(w http.ResponseWriter, r *http.Request) { - corsHeaders(w, r) + if !s.corsHeaders(w, r) { + http.Error(w, "origin not allowed", http.StatusForbidden) + return + } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) @@ -42,15 +55,36 @@ func (s *Server) handleOTLP(w http.ResponseWriter, r *http.Request) { return } + if !s.validateSameSite(r) { + http.Error(w, "cross-site request blocked", http.StatusForbidden) + return + } + + token := bearerToken(r) + if token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.ExportToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ip := clientIP(r) + if !s.ipLimiter.allow(ip) || !s.tokenLimiter.allow(token) { + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + // Map /__odigos/v1/ -> /v1/. signalPath := strings.TrimPrefix(r.URL.Path, config.OtlpPathPrefix) targetURL := s.cfg.OtlpHTTPEndpoint + "/v1/" + signalPath - body, err := io.ReadAll(io.LimitReader(r.Body, maxOTLPBodyBytes)) + body, err := io.ReadAll(io.LimitReader(r.Body, maxOTLPBodyBytes+1)) if err != nil { http.Error(w, "failed to read body", http.StatusBadRequest) return } + if len(body) > maxOTLPBodyBytes { + http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) + return + } req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { @@ -58,6 +92,7 @@ func (s *Server) handleOTLP(w http.ResponseWriter, r *http.Request) { return } // Preserve the payload framing so the collector can decode it (protobuf or json, possibly gzip). + // Do NOT forward Authorization — the export token is gateway-local only. copyHeader(req.Header, r.Header, "Content-Type") copyHeader(req.Header, r.Header, "Content-Encoding") @@ -76,6 +111,53 @@ func (s *Server) handleOTLP(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(w, io.LimitReader(resp.Body, maxOTLPBodyBytes)) } +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + const prefix = "Bearer " + if len(h) < len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { + return "" + } + return strings.TrimSpace(h[len(prefix):]) +} + +// validateSameSite rejects cross-site OTLP POSTs when Origin or Referer is present and mismatches Host. +func (s *Server) validateSameSite(r *http.Request) bool { + if origin := r.Header.Get("Origin"); origin != "" { + return sameSiteOrigin(origin, r.Host) + } + if referer := r.Header.Get("Referer"); referer != "" { + return sameSiteOrigin(referer, r.Host) + } + return true +} + +func sameSiteOrigin(originOrURL, requestHost string) bool { + u, err := url.Parse(originOrURL) + if err != nil || u.Host == "" { + return false + } + return hostsEqual(u.Host, requestHost) +} + +func hostsEqual(a, b string) bool { + ah, ap, _ := net.SplitHostPort(a) + if ah == "" { + ah = a + } + bh, bp, _ := net.SplitHostPort(b) + if bh == "" { + bh = b + } + if !strings.EqualFold(ah, bh) { + return false + } + // If both specify ports, require equality; if one omits port, treat as match on hostname. + if ap != "" && bp != "" && ap != bp { + return false + } + return true +} + func copyHeader(dst, src http.Header, key string) { if v := src.Get(key); v != "" { dst.Set(key, v) diff --git a/browser-proxy/internal/server/ratelimit.go b/browser-proxy/internal/server/ratelimit.go new file mode 100644 index 0000000000..93f8f80904 --- /dev/null +++ b/browser-proxy/internal/server/ratelimit.go @@ -0,0 +1,88 @@ +package server + +import ( + "net" + "net/http" + "sync" + "time" +) + +// rateLimiter is a simple per-key token bucket used to bound OTLP abuse. +type rateLimiter struct { + mu sync.Mutex + buckets map[string]*bucket + rate float64 // tokens per second + burst float64 + lastSweep time.Time +} + +type bucket struct { + tokens float64 + last time.Time + lastSeen time.Time +} + +func newRateLimiter(perMinute int, burst int) *rateLimiter { + if perMinute <= 0 { + perMinute = 120 + } + if burst <= 0 { + burst = perMinute / 4 + if burst < 5 { + burst = 5 + } + } + return &rateLimiter{ + buckets: make(map[string]*bucket), + rate: float64(perMinute) / 60.0, + burst: float64(burst), + lastSweep: time.Now(), + } +} + +func (l *rateLimiter) allow(key string) bool { + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + + if now.Sub(l.lastSweep) > 5*time.Minute { + l.sweepLocked(now) + l.lastSweep = now + } + + b := l.buckets[key] + if b == nil { + b = &bucket{tokens: l.burst, last: now, lastSeen: now} + l.buckets[key] = b + } + + elapsed := now.Sub(b.last).Seconds() + b.tokens += elapsed * l.rate + if b.tokens > l.burst { + b.tokens = l.burst + } + b.last = now + b.lastSeen = now + + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +func (l *rateLimiter) sweepLocked(now time.Time) { + for k, b := range l.buckets { + if now.Sub(b.lastSeen) > 10*time.Minute { + delete(l.buckets, k) + } + } +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/browser-proxy/internal/server/server.go b/browser-proxy/internal/server/server.go index 4e039c0a80..37168aedb1 100644 --- a/browser-proxy/internal/server/server.go +++ b/browser-proxy/internal/server/server.go @@ -1,11 +1,13 @@ // Package server implements the odigos-browser-proxy HTTP server: a reverse proxy in front of a -// web-server container that injects the OpenTelemetry browser SDK