From 6d5174450ee569d93aa6e8cde1b10ba11eed7146 Mon Sep 17 00:00:00 2001 From: Suvin Nimnaka Date: Thu, 1 Oct 2026 14:40:11 +0530 Subject: [PATCH 1/3] Add Dynatrace logs module Signed-off-by: Suvin Nimnaka --- .github/codecov.yml | 4 + observability-logs-dynatrace/.dockerignore | 28 + observability-logs-dynatrace/Dockerfile | 24 + observability-logs-dynatrace/Makefile | 18 + observability-logs-dynatrace/README.md | 315 +++ observability-logs-dynatrace/VERSION | 1 + observability-logs-dynatrace/go.mod | 22 + observability-logs-dynatrace/go.sum | 54 + observability-logs-dynatrace/helm/.helmignore | 23 + observability-logs-dynatrace/helm/Chart.lock | 6 + observability-logs-dynatrace/helm/Chart.yaml | 22 + .../helm/templates/_helpers.tpl | 83 + .../helm/templates/adapter/configmap.yaml | 26 + .../helm/templates/adapter/deployment.yaml | 77 + .../helm/templates/adapter/service.yaml | 22 + .../helm/templates/credentials-secret.yaml | 25 + .../helm/templates/fluent-bit/config.yaml | 272 +++ .../helm/templates/validate.yaml | 6 + observability-logs-dynatrace/helm/values.yaml | 193 ++ .../internal/api/cfg-models.yaml | 4 + .../internal/api/cfg-server.yaml | 6 + .../internal/api/gen/models.gen.go | 1310 +++++++++++++ .../internal/api/gen/server.gen.go | 1721 +++++++++++++++++ .../internal/audit_handlers.go | 395 ++++ .../internal/config.go | 117 ++ .../internal/config_test.go | 92 + .../internal/dynatrace/audit.go | 528 +++++ .../internal/dynatrace/auth.go | 138 ++ .../internal/dynatrace/client.go | 281 +++ .../internal/dynatrace/client_test.go | 485 +++++ .../internal/dynatrace/dql.go | 151 ++ .../internal/dynatrace/dql_test.go | 347 ++++ .../internal/dynatrace/events.go | 236 +++ .../internal/dynatrace/fields.go | 96 + .../internal/dynatrace/helpers_test.go | 93 + .../internal/dynatrace/level.go | 100 + .../internal/dynatrace/level_test.go | 162 ++ .../internal/dynatrace/logs.go | 290 +++ .../internal/dynatrace/platform.go | 252 +++ .../internal/dynatrace/values.go | 124 ++ .../internal/handlers.go | 354 ++++ .../internal/handlers_test.go | 334 ++++ .../internal/platform_handlers.go | 220 +++ .../internal/server.go | 61 + observability-logs-dynatrace/main.go | 90 + observability-logs-dynatrace/module.yaml | 11 + 46 files changed, 9219 insertions(+) create mode 100644 observability-logs-dynatrace/.dockerignore create mode 100644 observability-logs-dynatrace/Dockerfile create mode 100644 observability-logs-dynatrace/Makefile create mode 100644 observability-logs-dynatrace/README.md create mode 100644 observability-logs-dynatrace/VERSION create mode 100644 observability-logs-dynatrace/go.mod create mode 100644 observability-logs-dynatrace/go.sum create mode 100644 observability-logs-dynatrace/helm/.helmignore create mode 100644 observability-logs-dynatrace/helm/Chart.lock create mode 100644 observability-logs-dynatrace/helm/Chart.yaml create mode 100644 observability-logs-dynatrace/helm/templates/_helpers.tpl create mode 100644 observability-logs-dynatrace/helm/templates/adapter/configmap.yaml create mode 100644 observability-logs-dynatrace/helm/templates/adapter/deployment.yaml create mode 100644 observability-logs-dynatrace/helm/templates/adapter/service.yaml create mode 100644 observability-logs-dynatrace/helm/templates/credentials-secret.yaml create mode 100644 observability-logs-dynatrace/helm/templates/fluent-bit/config.yaml create mode 100644 observability-logs-dynatrace/helm/templates/validate.yaml create mode 100644 observability-logs-dynatrace/helm/values.yaml create mode 100644 observability-logs-dynatrace/internal/api/cfg-models.yaml create mode 100644 observability-logs-dynatrace/internal/api/cfg-server.yaml create mode 100644 observability-logs-dynatrace/internal/api/gen/models.gen.go create mode 100644 observability-logs-dynatrace/internal/api/gen/server.gen.go create mode 100644 observability-logs-dynatrace/internal/audit_handlers.go create mode 100644 observability-logs-dynatrace/internal/config.go create mode 100644 observability-logs-dynatrace/internal/config_test.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/audit.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/auth.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/client.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/client_test.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/dql.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/dql_test.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/events.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/fields.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/helpers_test.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/level.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/level_test.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/logs.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/platform.go create mode 100644 observability-logs-dynatrace/internal/dynatrace/values.go create mode 100644 observability-logs-dynatrace/internal/handlers.go create mode 100644 observability-logs-dynatrace/internal/handlers_test.go create mode 100644 observability-logs-dynatrace/internal/platform_handlers.go create mode 100644 observability-logs-dynatrace/internal/server.go create mode 100644 observability-logs-dynatrace/main.go create mode 100644 observability-logs-dynatrace/module.yaml diff --git a/.github/codecov.yml b/.github/codecov.yml index c0134f09..48666f7e 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -36,6 +36,10 @@ component_management: name: observability-logs-aws-cloudwatch paths: - observability-logs-aws-cloudwatch/** + - component_id: observability_logs_dynatrace + name: observability-logs-dynatrace + paths: + - observability-logs-dynatrace/** - component_id: observability_logs_gcp_cloudlogging name: observability-logs-gcp-cloudlogging paths: diff --git a/observability-logs-dynatrace/.dockerignore b/observability-logs-dynatrace/.dockerignore new file mode 100644 index 00000000..c5931d9c --- /dev/null +++ b/observability-logs-dynatrace/.dockerignore @@ -0,0 +1,28 @@ +# Git +.git +.gitignore + +# Documentation +README.md +*.md + +# Kubernetes manifests +k8s/ +helm/ + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Test files +*_test.go + +# Build artifacts +*.exe +*.exe~ +*.dll +*.so +*.dylib diff --git a/observability-logs-dynatrace/Dockerfile b/observability-logs-dynatrace/Dockerfile new file mode 100644 index 00000000..8efa2016 --- /dev/null +++ b/observability-logs-dynatrace/Dockerfile @@ -0,0 +1,24 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +FROM golang:1.27-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +FROM alpine:3.24 + +RUN apk --no-cache add ca-certificates && \ + addgroup -g 10500 appuser && \ + adduser -D -u 10500 -G appuser appuser + +WORKDIR /home/appuser +COPY --from=builder --chown=appuser:appuser --chmod=0550 /app/main . + +USER appuser +EXPOSE 9098 + +CMD ["./main"] diff --git a/observability-logs-dynatrace/Makefile b/observability-logs-dynatrace/Makefile new file mode 100644 index 00000000..7256863b --- /dev/null +++ b/observability-logs-dynatrace/Makefile @@ -0,0 +1,18 @@ +CFG_DIR := internal/api +OAPI_CODEGEN_VERSION ?= v2.5.1 +SPEC := https://raw.githubusercontent.com/openchoreo/openchoreo/main/openapi/observability-logs-adapter-api.yaml + +.PHONY: oapi-codegen-install openapi-codegen unit-test + +oapi-codegen-install: + go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) + +openapi-codegen: oapi-codegen-install + cd $(CFG_DIR) && $(shell go env GOPATH)/bin/oapi-codegen --config cfg-models.yaml $(SPEC) + cd $(CFG_DIR) && $(shell go env GOPATH)/bin/oapi-codegen --config cfg-server.yaml $(SPEC) + +MODULE_NAME := $(notdir $(CURDIR)) + +unit-test: + go test -coverprofile=coverage.out ./... + mv coverage.out ../$(MODULE_NAME)-coverage.out diff --git a/observability-logs-dynatrace/README.md b/observability-logs-dynatrace/README.md new file mode 100644 index 00000000..c1575f25 --- /dev/null +++ b/observability-logs-dynatrace/README.md @@ -0,0 +1,315 @@ +# Observability Logs Module for Dynatrace + +| | | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Code coverage | [![Codecov](https://codecov.io/gh/openchoreo/community-modules/branch/main/graph/badge.svg?component=observability_logs_dynatrace)](https://codecov.io/gh/openchoreo/community-modules) | + +This module collects container logs using [Fluent Bit](https://fluentbit.io), ships them to [Dynatrace](https://www.dynatrace.com) through the Log Ingest API, and serves them back to the OpenChoreo Observer from [Grail](https://docs.dynatrace.com/docs/platform/grail) with DQL. + +It has two parts: + +- **Fluent Bit** (a DaemonSet) tails container logs, flattens their Kubernetes metadata into Grail fields with a Lua filter, and posts them to `/api/v2/logs/ingest`. +- **The logs adapter** (a Deployment) implements the [logging adapter API](https://github.com/openchoreo/openchoreo/blob/main/openapi/observability-logs-adapter-api.yaml) by running DQL against the Grail query API. + +| Capability | Supported | +| ----------------------------------- | ----------------------------------------------------------------- | +| Component and workflow logs | Yes | +| Platform logs and filter values | Yes | +| Audit logs, filter values, timeline | Yes, with `auditLogs.enabled=true` | +| Kubernetes events | Yes, shipped by `observability-events-otel-collector` (see below) | +| Log alert rules | No, the alert endpoints answer `501 Not Implemented` | + +> **Note:** The commands in this README install the latest module version. Refer to the [Compatibility](#compatibility) table below for the module version compatible with your OpenChoreo version. + +## Prerequisites + +- [OpenChoreo](https://openchoreo.dev) must be installed with the **observability plane** enabled for this module to work. The observer reaches this module's adapter at `http://logs-adapter:9098`, which is the default value of `observer.logsAdapter.url` in the `openchoreo-observability-plane` helm chart, so no change to the observability plane is needed unless that value was overridden. +- A Dynatrace SaaS environment with Grail and OpenPipeline (SaaS 1.295 or later). On older environments that route logs to the Classic pipeline, attribute keys are lowercased and arrays are stringified, which breaks the label and entitlement filters. + +## Dynatrace credentials + +Create these in Dynatrace: + +| Credential | Used by | Scopes | +| ----------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------- | +| [Access token](https://docs.dynatrace.com/docs/manage/identity-access-management/access-tokens-and-oauth-clients/access-tokens) | Fluent Bit | `logs.ingest` | +| [Platform token](https://docs.dynatrace.com/docs/manage/identity-access-management/access-tokens-and-oauth-clients/platform-tokens) | Adapter | `storage:logs:read`, `storage:buckets:read` | + +Instead of the access token, Fluent Bit can send a platform token with `openpipeline:logs:ingest` by setting `dynatrace.ingest.authScheme=Bearer`. Instead of the platform token, the adapter can use an OAuth client (`adapter.authMode=oauth`) with the same read scopes. + +OpenChoreo uses the External Secrets Operator to manage secrets. Add the tokens to a secret store and use an `ExternalSecret` to generate a Kubernetes secret named `dynatrace-credentials` in `openchoreo-observability-plane`. Refer to the [secret management guide](https://openchoreo.dev/docs/platform-engineer-guide/secret-management/) for more details. + +For example, the commands below add the tokens to OpenBao and pull them from the `ClusterSecretStore` created earlier in the [OpenChoreo installation guide](https://openchoreo.dev/docs). + +```bash +kubectl exec -it -n openbao openbao-0 -- \ + bao kv put secret/dynatrace-credentials \ + DT_INGEST_TOKEN='dt0c01.XXXX' \ + DT_PLATFORM_TOKEN='dt0s16.XXXX' +``` + +```bash +kubectl apply -f - <.apps.dynatrace.com \ + --set fluentBitCustomizations.clusterInstance= +``` + +The log ingest URL defaults to `https://.live.dynatrace.com`, derived from `dynatrace.platformUrl`. Set `dynatrace.ingestUrl` to ship through an ActiveGate instead, e.g. `https://:9999/e/`. + +> **Note:** `fluentBitCustomizations.clusterInstance` is required. It names the cluster the records were collected from and is stamped on every record as `k8s.cluster.name`, so pick a value that is unique across the clusters reporting to this Dynatrace environment. + +### Multi-cluster topology + +Each cluster ships straight to Dynatrace, so no ingress into the observability plane is needed. On the observability plane cluster, install the chart as above. On each remote cluster (data plane, workflow plane, control plane), install it with only Fluent Bit: + +```bash +helm upgrade --install observability-logs-dynatrace \ + oci://ghcr.io/openchoreo/helm-charts/observability-logs-dynatrace \ + --create-namespace \ + --namespace openchoreo-observability-plane \ + --version 0.0.0-latest-dev \ + --set adapter.enabled=false \ + --set dynatrace.ingestUrl=https://.live.dynatrace.com \ + --set fluentBitCustomizations.clusterInstance= +``` + +The `dynatrace-credentials` secret must exist on every remote cluster, but only needs `DT_INGEST_TOKEN` there. + +## Kubernetes events + +Events are collected by the [`observability-events-otel-collector`](../observability-events-otel-collector) module, which exports them to Dynatrace's OTLP endpoint. The adapter reads them back by their `k8s.event.reason` and `k8s.object.label.openchoreo.dev/*` attributes. + +```bash +helm upgrade --install observability-events-otel-collector \ + oci://ghcr.io/openchoreo/helm-charts/observability-events-otel-collector \ + --namespace openchoreo-observability-plane --version 0.2.0 \ + -f - <<'EOF' +collector: + extraEnv: + - name: DT_INGEST_TOKEN + valueFrom: + secretKeyRef: + name: dynatrace-credentials + key: DT_INGEST_TOKEN +exporters: + otlphttp/dynatrace: + endpoint: "https://.live.dynatrace.com/api/v2/otlp" + headers: + Authorization: "Api-Token ${env:DT_INGEST_TOKEN}" +pipelineExporters: + - otlphttp/dynatrace +EOF +``` + +## Enable audit log collection + +OpenChoreo's audit trail is written by `openchoreo-api` and `observer` to their container logs. With `auditLogs.enabled=true`, Fluent Bit routes those records to their own `log.source` (`openchoreo-audit-logs` by default) and lifts their filterable fields to `audit.*` Grail fields. + +```bash +helm upgrade observability-logs-dynatrace \ + oci://ghcr.io/openchoreo/helm-charts/observability-logs-dynatrace \ + --namespace openchoreo-observability-plane \ + --version 0.0.0-latest-dev \ + --reuse-values \ + --set auditLogs.enabled=true +``` + +In a multi-cluster topology, set `auditLogs.enabled` on the cluster running `openchoreo-api` (the control plane) and on the one running `observer` (the observability plane). + +### Trusted producers + +`auditLogs.producers` is an allowlist. **Each entry grants a workload the right to write into the audit trail.** Entries match on the container log filename the kubelet writes, never on the content of the log line, so a pod outside the list that prints an audit-shaped line still lands in the container logs. + +```yaml +auditLogs: + producers: + - producer: openchoreo-api + namespace: openchoreo-control-plane + container: api-server + - producer: observer + namespace: openchoreo-observability-plane + container: observer +``` + +If the control plane or observability plane is installed into a non-default namespace, edit these entries, or audit collection silently stops. + +### Audit retention + +Grail keeps logs in the `default_logs` bucket for 35 days. To keep the audit trail for a year: + +1. Create a bucket in **Settings > Storage management > Bucket storage management**, e.g. `openchoreo_audit_logs`, with table type `logs` and a retention of 365 days. +2. In **Settings > Process and contextualize > OpenPipeline > Logs > Pipelines**, add a *Bucket assignment* processor with the matching condition `log.source == "openchoreo-audit-logs"` that stores into that bucket. +3. Set `auditLogs.bucket=openchoreo_audit_logs`, so the adapter reads audit records from that bucket only. + +### Configuration + +| Value | Default | Purpose | +| --------------------------- | ----------------------- | ------------------------------------------------- | +| `auditLogs.enabled` | `false` | Route audit records to their own `log.source` | +| `auditLogs.producers` | the two above | The trusted-producer allowlist | +| `auditLogs.bucket` | `""` | Grail bucket the adapter reads audit records from | +| `dynatrace.auditLogsSource` | `openchoreo-audit-logs` | `log.source` stamped on audit records | + +## How records are stored + +The adapter queries fields written by the module's own Fluent Bit Lua filter, so it only ever reads records this module shipped (`log.source == dynatrace.containerLogsSource`): + +| Grail field | Source | +| ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `content`, `timestamp` | The log line and its CRI timestamp | +| `k8s.cluster.name` | `fluentBitCustomizations.clusterInstance` | +| `k8s.namespace.name`, `k8s.pod.name`, `k8s.container.name`, `k8s.node.name`, `k8s.pod.ip`, `k8s.pod.uid`, `container.image.name` | Kubernetes metadata | +| `k8s.pod.labels` | Every pod label, as a `key=value` string array | +| `openchoreo.namespace`, `openchoreo.project[_uid]`, `openchoreo.component[_uid]`, `openchoreo.environment[_uid]`, `openchoreo.workflow_run` | OpenChoreo pod labels | + +Log levels are derived from the message text, as the OpenSearch and OpenObserve modules do. + +## Applying Fluent Bit configuration changes + +Fluent Bit reads its configuration from the `fluent-bit` ConfigMap rendered by this chart, and only at startup. A `helm upgrade` that changes it does not restart the Fluent Bit pods. Restart the DaemonSet after such an upgrade, in every cluster where the release was upgraded: + +```bash +kubectl rollout restart daemonset/fluent-bit -n openchoreo-observability-plane +``` + +The same applies after the `dynatrace-credentials` secret changes. The adapter restarts on its own when its configuration changes. + +## Limitations + +- **Alerting** is not implemented: create, update, get and delete of alert rules answer `501`. Configure log alerting in Dynatrace with anomaly detectors and workflows. +- **Record age:** Dynatrace rejects records older than 24 hours. On first install Fluent Bit reads existing log files from the start (`fluentBitCustomizations.readFromHead`), so older lines on a long-running node are dropped. +- **Array size:** Grail keeps at most 32 values per attribute, so a pod with more than 32 labels, or an audit actor with more than 32 entitlement values, has the rest dropped from `k8s.pod.labels` or `audit.actor.entitlements`. +- **Renaming the secret:** Fluent Bit's environment comes from the `fluent-bit` subchart's values. If you change `dynatrace.credentials.name`, also override `fluent-bit.env` to point at it. + +## Troubleshooting + +Start with the adapter and Fluent Bit logs: + +```bash +kubectl -n openchoreo-observability-plane logs deploy/logs-adapter-dynatrace --tail=50 +kubectl -n openchoreo-observability-plane logs ds/fluent-bit --tail=50 | grep 'output:http' +``` + +A healthy adapter logs `Connected to Dynatrace Grail` at startup, and every Fluent Bit flush reports `HTTP status=204`. + +### Adapter is in `CrashLoopBackOff` + +The adapter queries Grail at startup and exits if it cannot. + +- `Failed to query Dynatrace Grail` with a `401` or `403` in the error: the platform token is wrong, lacks `storage:logs:read` or `storage:buckets:read`, or its user has no policy that grants reading the buckets involved. +- `DT_PLATFORM_URL must be an http(s) URL`: set `dynatrace.platformUrl` to the **apps** URL with its scheme, `https://.apps.dynatrace.com`. + +### Fluent Bit reports a status other than `204` + +| Status | Meaning | +| ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `200` | Partial success: some records were rejected, usually because they are older than 24 hours | +| `400` | The whole batch was rejected. On first install this is normally old log lines, and it stops once Fluent Bit catches up | +| `401`, `403` | The ingest token is wrong or lacks the `logs.ingest` scope | + +If `400` persists, set `Log_Level debug` in the Fluent Bit configuration to see the response body. + +### Fluent Bit pod is stuck in `ContainerCreating` on k3d + +The pod events show `/etc/machine-id is not a file`. k3d nodes have no machine ID. Create one on each node: + +```bash +docker exec k3d--server-0 sh -c "cat /proc/sys/kernel/random/uuid | tr -d '-' > /etc/machine-id" +``` + +### Records are in Dynatrace but the Observer returns nothing + +- Check that the Observer reaches this module's adapter: the `logs-adapter` Service must select `app=logs-adapter-dynatrace`. If another logs module was installed before, its adapter may still own the Service. +- If platform logs work but component logs are empty, the pods lack the `openchoreo.dev/*` labels, or the query uses the wrong component or environment UID. +- Set `adapter.logLevel=debug` to log every DQL query the adapter runs, and run one in a Dynatrace Notebook to see what Grail returns. + +### `k8s.pod.labels` is a single string and label filters match nothing + +The environment routes logs through the Classic pipeline, which lowercases attribute keys and stringifies arrays. Enable OpenPipeline for logs. + +### Kubernetes events are empty + +Check the events collector logs for `Exporting failed`. Its endpoint must be `https://.live.dynatrace.com/api/v2/otlp`, not the `apps` URL. + +## Configuration reference + +| Value | Default | Description | +| ----------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `dynatrace.platformUrl` | Required | Grail API base URL, `https://.apps.dynatrace.com`. Required when `adapter.enabled=true`. | +| `dynatrace.ingestUrl` | Derived from `platformUrl` | Log ingest base URL. Set it to ship through an ActiveGate, or on clusters that run only Fluent Bit. | +| `dynatrace.ingest.authScheme` | `Api-Token` | `Api-Token` for an access token, `Bearer` for a platform or OAuth token with `openpipeline:logs:ingest`. | +| `dynatrace.ingest.tlsVerify` | `true` | Verify the ingest endpoint's TLS certificate. | +| `dynatrace.containerLogsSource` | `openchoreo-container-logs` | `log.source` stamped on container logs. Must match on every cluster reporting to one environment. | +| `dynatrace.auditLogsSource` | `openchoreo-audit-logs` | `log.source` stamped on audit records. Must differ from `containerLogsSource`. | +| `dynatrace.credentials.name` | `dynatrace-credentials` | Secret holding the Dynatrace credentials, in the release namespace. | +| `dynatrace.credentials.create` | `false` | Have the chart create the Secret from the inline values below. For trials only. | +| `auditLogs.enabled` | `false` | Route audit records to their own `log.source`. | +| `auditLogs.producers` | `openchoreo-api`, `observer` | The trusted-producer allowlist. See [Trusted producers](#trusted-producers). | +| `auditLogs.bucket` | `""` | Grail bucket the adapter reads audit records from. Empty reads every bucket the token can see. | +| `fluent-bit.enabled` | `true` | Toggle the Fluent Bit DaemonSet. | +| `fluentBitCustomizations.clusterInstance` | Required | Cluster name stamped on every record as `k8s.cluster.name`. Required when Fluent Bit is enabled. | +| `fluentBitCustomizations.excludePaths` | `/var/log/containers/*_kube-system_*.log` | Container log files that are not shipped. Fluent Bit's own logs are always excluded. | +| `fluentBitCustomizations.readFromHead` | `true` | Read container log files from the beginning on first start. | +| `adapter.enabled` | `true` | Toggle the adapter Deployment. | +| `adapter.authMode` | `platformToken` | `platformToken` or `oauth`. | +| `adapter.oauth.tokenUrl` | `https://sso.dynatrace.com/sso/oauth2/token` | OAuth token endpoint, when `authMode=oauth`. | +| `adapter.oauth.scope` | `storage:logs:read storage:buckets:read` | OAuth scopes requested. | +| `adapter.oauth.resource` | `""` | Optional resource URN, e.g. `urn:dtaccount:`. | +| `adapter.queryTimeout` | `30s` | Upper bound for one Grail query, including polling. | +| `adapter.logLevel` | `info` | `debug`, `info`, `warn` or `error`. | +| `adapter.image.repository` | `ghcr.io/openchoreo/observability-logs-dynatrace-adapter` | Adapter container image. | +| `adapter.image.tag` | Chart `appVersion` | Image tag. | +| `adapter.image.pullPolicy` | `IfNotPresent` | Image pull policy. | +| `adapter.resources` | `200m/256Mi` limits, `50m/128Mi` requests | Adapter resource requests and limits. | + +## Dependencies + +Bundled upstream Helm charts: + +| Chart | Repository | +| ---------- | ------------------------------------ | +| fluent-bit | https://fluent.github.io/helm-charts | + +## Compatibility + +> **Note:** The Helm chart versions specified in the installation commands above are for the latest module version compatible with the development version of OpenChoreo. Refer to the compatibility table below to determine the appropriate module version for your OpenChoreo installation. + +| OpenChoreo Version | Module Version | +| ------------------ | -------------- | +| v1.3.0 and later | 0.1.x | diff --git a/observability-logs-dynatrace/VERSION b/observability-logs-dynatrace/VERSION new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/observability-logs-dynatrace/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/observability-logs-dynatrace/go.mod b/observability-logs-dynatrace/go.mod new file mode 100644 index 00000000..3cc89058 --- /dev/null +++ b/observability-logs-dynatrace/go.mod @@ -0,0 +1,22 @@ +module github.com/openchoreo/community-modules/observability-logs-dynatrace + +go 1.26.0 + +require ( + github.com/getkin/kin-openapi v0.149.0 + github.com/google/uuid v1.6.0 + github.com/oapi-codegen/runtime v1.7.0 + golang.org/x/sync v0.23.0 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/observability-logs-dynatrace/go.sum b/observability-logs-dynatrace/go.sum new file mode 100644 index 00000000..6c323c09 --- /dev/null +++ b/observability-logs-dynatrace/go.sum @@ -0,0 +1,54 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA= +github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/observability-logs-dynatrace/helm/.helmignore b/observability-logs-dynatrace/helm/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/observability-logs-dynatrace/helm/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/observability-logs-dynatrace/helm/Chart.lock b/observability-logs-dynatrace/helm/Chart.lock new file mode 100644 index 00000000..0f0e1035 --- /dev/null +++ b/observability-logs-dynatrace/helm/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: fluent-bit + repository: https://fluent.github.io/helm-charts + version: 0.56.0 +digest: sha256:e120960dbb5fbcfb6bccdd7bb486bd94fd013387bfd4490fada383d43f492cf5 +generated: "2026-09-25T13:39:45.338966+05:30" diff --git a/observability-logs-dynatrace/helm/Chart.yaml b/observability-logs-dynatrace/helm/Chart.yaml new file mode 100644 index 00000000..65c0a3f3 --- /dev/null +++ b/observability-logs-dynatrace/helm/Chart.yaml @@ -0,0 +1,22 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v2 +name: observability-logs-dynatrace +description: A Helm chart for OpenChoreo Logs Module for Dynatrace +type: application +version: 0.0.0-latest-dev +appVersion: "latest-dev" +keywords: + - dynatrace + - grail + - openchoreo + - logs +maintainers: + - name: OpenChoreo Team +home: https://github.com/openchoreo/community-modules +dependencies: + - name: fluent-bit + version: "0.56.0" + repository: https://fluent.github.io/helm-charts + condition: fluent-bit.enabled diff --git a/observability-logs-dynatrace/helm/templates/_helpers.tpl b/observability-logs-dynatrace/helm/templates/_helpers.tpl new file mode 100644 index 00000000..d8965b41 --- /dev/null +++ b/observability-logs-dynatrace/helm/templates/_helpers.tpl @@ -0,0 +1,83 @@ +{{/* +Copyright 2026 The OpenChoreo Authors +SPDX-License-Identifier: Apache-2.0 +*/}} + +{{/* +Log ingest base URL: dynatrace.ingestUrl, or dynatrace.platformUrl with ".apps." replaced +by ".live.", which is where SaaS serves the classic API. +*/}} +{{- define "logs-dynatrace.ingestUrl" -}} +{{- if .Values.dynatrace.ingestUrl -}} +{{- trimSuffix "/" .Values.dynatrace.ingestUrl -}} +{{- else if .Values.dynatrace.platformUrl -}} +{{- trimSuffix "/" (replace ".apps." ".live." .Values.dynatrace.platformUrl) -}} +{{- end -}} +{{- end -}} + +{{/* +The ingest URL split into the pieces Fluent Bit's http output takes separately, as a +JSON dict: host, port, tls, basePath. Port defaults from the scheme. +*/}} +{{- define "logs-dynatrace.ingestEndpoint" -}} +{{- $raw := include "logs-dynatrace.ingestUrl" . -}} +{{- $u := urlParse $raw -}} +{{- if not (has $u.scheme (list "http" "https")) -}} +{{- fail (printf "dynatrace.ingestUrl %q must start with http:// or https://" $raw) -}} +{{- end -}} +{{- $tls := eq $u.scheme "https" -}} +{{- $hostPort := splitList ":" $u.host -}} +{{- $host := first $hostPort -}} +{{- $port := ternary "443" "80" $tls -}} +{{- if eq (len $hostPort) 2 -}} +{{- $port = last $hostPort -}} +{{- end -}} +{{- dict "host" $host "port" $port "tls" $tls "basePath" (trimSuffix "/" $u.path) | toJson -}} +{{- end -}} + +{{/* +Validate required values and fail fast with a readable message. +*/}} +{{- define "logs-dynatrace.validate" -}} +{{- if .Values.adapter.enabled -}} +{{- if not .Values.dynatrace.platformUrl -}} +{{- fail "dynatrace.platformUrl is required when adapter.enabled=true. Example: --set dynatrace.platformUrl=https://abc12345.apps.dynatrace.com" -}} +{{- end -}} +{{- if not (has .Values.adapter.authMode (list "platformToken" "oauth")) -}} +{{- fail (printf "adapter.authMode must be platformToken or oauth, got %q" .Values.adapter.authMode) -}} +{{- end -}} +{{- end -}} +{{- if (index .Values "fluent-bit").enabled -}} +{{- if not (include "logs-dynatrace.ingestUrl" .) -}} +{{- fail "dynatrace.ingestUrl (or dynatrace.platformUrl) is required when fluent-bit.enabled=true" -}} +{{- end -}} +{{- if not .Values.fluentBitCustomizations.clusterInstance -}} +{{- fail "fluentBitCustomizations.clusterInstance is required. It names the cluster these records came from and cannot be defaulted." -}} +{{- end -}} +{{- if not (regexMatch "^[A-Za-z0-9._-]+$" .Values.fluentBitCustomizations.clusterInstance) -}} +{{- fail (printf "fluentBitCustomizations.clusterInstance %q may contain only letters, digits, '.', '_' and '-'" .Values.fluentBitCustomizations.clusterInstance) -}} +{{- end -}} +{{- if not (has .Values.dynatrace.ingest.authScheme (list "Api-Token" "Bearer")) -}} +{{- fail (printf "dynatrace.ingest.authScheme must be Api-Token or Bearer, got %q" .Values.dynatrace.ingest.authScheme) -}} +{{- end -}} +{{- end -}} +{{- range $k := list "containerLogsSource" "auditLogsSource" -}} +{{- if not (regexMatch "^[A-Za-z0-9._-]+$" (index $.Values.dynatrace $k)) -}} +{{- fail (printf "dynatrace.%s may contain only letters, digits, '.', '_' and '-'" $k) -}} +{{- end -}} +{{- end -}} +{{- if eq .Values.dynatrace.containerLogsSource .Values.dynatrace.auditLogsSource -}} +{{- fail "dynatrace.containerLogsSource and dynatrace.auditLogsSource must differ" -}} +{{- end -}} +{{- if .Values.dynatrace.credentials.create -}} +{{- if and (index .Values "fluent-bit").enabled (not .Values.dynatrace.credentials.ingestToken) -}} +{{- fail "dynatrace.credentials.ingestToken is required when dynatrace.credentials.create=true and fluent-bit.enabled=true" -}} +{{- end -}} +{{- if and .Values.adapter.enabled (eq .Values.adapter.authMode "platformToken") (not .Values.dynatrace.credentials.platformToken) -}} +{{- fail "dynatrace.credentials.platformToken is required when dynatrace.credentials.create=true and adapter.authMode=platformToken" -}} +{{- end -}} +{{- if and .Values.adapter.enabled (eq .Values.adapter.authMode "oauth") (not (and .Values.dynatrace.credentials.oauthClientId .Values.dynatrace.credentials.oauthClientSecret)) -}} +{{- fail "dynatrace.credentials.oauthClientId and oauthClientSecret are required when dynatrace.credentials.create=true and adapter.authMode=oauth" -}} +{{- end -}} +{{- end -}} +{{- end -}} diff --git a/observability-logs-dynatrace/helm/templates/adapter/configmap.yaml b/observability-logs-dynatrace/helm/templates/adapter/configmap.yaml new file mode 100644 index 00000000..16e31ece --- /dev/null +++ b/observability-logs-dynatrace/helm/templates/adapter/configmap.yaml @@ -0,0 +1,26 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +{{- if .Values.adapter.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: logs-adapter-dynatrace + namespace: {{ .Release.Namespace }} + labels: + app: logs-adapter-dynatrace +data: + SERVER_PORT: "9098" + LOG_LEVEL: {{ .Values.adapter.logLevel | quote }} + DT_PLATFORM_URL: {{ trimSuffix "/" .Values.dynatrace.platformUrl | quote }} + DT_AUTH_MODE: {{ .Values.adapter.authMode | quote }} + DT_QUERY_TIMEOUT: {{ .Values.adapter.queryTimeout | quote }} + DT_CONTAINER_LOGS_SOURCE: {{ .Values.dynatrace.containerLogsSource | quote }} + DT_AUDIT_LOGS_SOURCE: {{ .Values.dynatrace.auditLogsSource | quote }} + DT_AUDIT_BUCKET: {{ .Values.auditLogs.bucket | quote }} +{{- if eq .Values.adapter.authMode "oauth" }} + DT_OAUTH_TOKEN_URL: {{ .Values.adapter.oauth.tokenUrl | quote }} + DT_OAUTH_SCOPE: {{ .Values.adapter.oauth.scope | quote }} + DT_OAUTH_RESOURCE: {{ .Values.adapter.oauth.resource | quote }} +{{- end }} +{{- end }} diff --git a/observability-logs-dynatrace/helm/templates/adapter/deployment.yaml b/observability-logs-dynatrace/helm/templates/adapter/deployment.yaml new file mode 100644 index 00000000..dabd8659 --- /dev/null +++ b/observability-logs-dynatrace/helm/templates/adapter/deployment.yaml @@ -0,0 +1,77 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +{{- if .Values.adapter.enabled }} +{{- $c := .Values.dynatrace.credentials }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: logs-adapter-dynatrace + namespace: {{ .Release.Namespace }} + labels: + app: logs-adapter-dynatrace +spec: + replicas: 1 + selector: + matchLabels: + app: logs-adapter-dynatrace + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/adapter/configmap.yaml") . | sha256sum }} + labels: + app: logs-adapter-dynatrace + spec: + securityContext: + runAsUser: 10500 + runAsGroup: 10500 + runAsNonRoot: true + containers: + - name: logs-adapter-dynatrace + image: "{{ .Values.adapter.image.repository }}:{{ .Values.adapter.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.adapter.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: http + containerPort: 9098 + envFrom: + - configMapRef: + name: logs-adapter-dynatrace + env: +{{- if eq .Values.adapter.authMode "oauth" }} + - name: DT_OAUTH_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ $c.name | quote }} + key: {{ $c.oauthClientIdKey | quote }} + - name: DT_OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ $c.name | quote }} + key: {{ $c.oauthClientSecretKey | quote }} +{{- else }} + - name: DT_PLATFORM_TOKEN + valueFrom: + secretKeyRef: + name: {{ $c.name | quote }} + key: {{ $c.platformTokenKey | quote }} +{{- end }} + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + {{- toYaml .Values.adapter.resources | nindent 10 }} +{{- end }} diff --git a/observability-logs-dynatrace/helm/templates/adapter/service.yaml b/observability-logs-dynatrace/helm/templates/adapter/service.yaml new file mode 100644 index 00000000..4b472127 --- /dev/null +++ b/observability-logs-dynatrace/helm/templates/adapter/service.yaml @@ -0,0 +1,22 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +{{- if .Values.adapter.enabled }} +apiVersion: v1 +kind: Service +metadata: + # The observer reaches the logs adapter at http://logs-adapter:9098 by default. + name: logs-adapter + namespace: {{ .Release.Namespace }} + labels: + app: logs-adapter-dynatrace +spec: + type: ClusterIP + ports: + - port: 9098 + targetPort: 9098 + protocol: TCP + name: http + selector: + app: logs-adapter-dynatrace +{{- end }} diff --git a/observability-logs-dynatrace/helm/templates/credentials-secret.yaml b/observability-logs-dynatrace/helm/templates/credentials-secret.yaml new file mode 100644 index 00000000..caf70332 --- /dev/null +++ b/observability-logs-dynatrace/helm/templates/credentials-secret.yaml @@ -0,0 +1,25 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +{{- if .Values.dynatrace.credentials.create }} +{{- $c := .Values.dynatrace.credentials }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $c.name }} + namespace: {{ .Release.Namespace }} +type: Opaque +stringData: + {{- with $c.ingestToken }} + {{ $c.ingestTokenKey }}: {{ . | quote }} + {{- end }} + {{- with $c.platformToken }} + {{ $c.platformTokenKey }}: {{ . | quote }} + {{- end }} + {{- with $c.oauthClientId }} + {{ $c.oauthClientIdKey }}: {{ . | quote }} + {{- end }} + {{- with $c.oauthClientSecret }} + {{ $c.oauthClientSecretKey }}: {{ . | quote }} + {{- end }} +{{- end }} diff --git a/observability-logs-dynatrace/helm/templates/fluent-bit/config.yaml b/observability-logs-dynatrace/helm/templates/fluent-bit/config.yaml new file mode 100644 index 00000000..380c0b1e --- /dev/null +++ b/observability-logs-dynatrace/helm/templates/fluent-bit/config.yaml @@ -0,0 +1,272 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +{{- if (index .Values "fluent-bit").enabled }} +{{- /* Validate first, so a missing URL fails with the readable message, not a parse error. */}} +{{- include "logs-dynatrace.validate" . }} +{{- $ep := include "logs-dynatrace.ingestEndpoint" . | fromJson }} +{{- $excludes := concat (list "/var/log/containers/fluent-bit-*.log") .Values.fluentBitCustomizations.excludePaths }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: fluent-bit + namespace: {{ .Release.Namespace }} +data: + fluent-bit.conf: | + [SERVICE] + Flush 1 + Daemon Off + Log_Level info + Parsers_File /fluent-bit/etc/conf/parsers.conf + HTTP_Server On + HTTP_Listen 0.0.0.0 + HTTP_Port 2020 + + [INPUT] + Name tail + Buffer_Chunk_Size 32KB + Buffer_Max_Size 2MB + DB /var/lib/fluent-bit/db/tail-container-logs.db + Exclude_Path {{ join "," $excludes }} + Inotify_Watcher false + Mem_Buf_Limit 100MB + Path /var/log/containers/*.log + multiline.parser docker, cri + Read_from_Head {{ if .Values.fluentBitCustomizations.readFromHead }}On{{ else }}Off{{ end }} + Refresh_Interval 5 + Skip_Long_Lines On + Tag kube.* + + [FILTER] + Name kubernetes + Buffer_Size 15MB + K8S-Logging.Parser On + K8S-Logging.Exclude On + Keep_Log On + Match kube.* + Merge_Log Off + Labels On + Annotations Off + tls.verify Off + Use_Kubelet true + + # Cluster identity stamped by the logs collector, and the log.source the adapter scopes + # its queries to. Set before audit records are split off, so they carry both too. + [FILTER] + Name modify + Match kube.* + Set k8s.cluster.name {{ .Values.fluentBitCustomizations.clusterInstance }} + Set log.source {{ .Values.dynatrace.containerLogsSource }} +{{- if .Values.auditLogs.enabled }} +{{ range $p := .Values.auditLogs.producers }} + [FILTER] + Name rewrite_tag + Match kube.var.log.containers.*_{{ $p.namespace }}_{{ $p.container }}-* + Rule $log AUDIT-LOG(?!-) audit.{{ $p.producer }} false + Emitter_Name audit_emitter_{{ $p.producer }} +{{ end }} + [FILTER] + Name parser + Match audit.* + Key_Name log + Parser audit_json + Reserve_Data On + Preserve_Key On + + [FILTER] + Name modify + Match audit.* + Set log.source {{ .Values.dynatrace.auditLogsSource }} + + [FILTER] + Name lua + Match audit.* + script /fluent-bit/etc/conf/dynatrace.lua + call dt_audit_record +{{- end }} + + # Flatten Fluent Bit's nested Kubernetes metadata into the flat Grail fields the adapter + # queries. Dynatrace stores nested objects as JSON strings, which DQL cannot filter on. + [FILTER] + Name lua + Match kube.* + script /fluent-bit/etc/conf/dynatrace.lua + call dt_container_record + + [OUTPUT] + Name http + Match kube.* + Host {{ $ep.host }} + Port {{ $ep.port }} + URI {{ $ep.basePath }}/api/v2/logs/ingest + Format json + json_date_key timestamp + json_date_format iso8601 + Header Authorization {{ .Values.dynatrace.ingest.authScheme }} ${DT_INGEST_TOKEN} + Header Content-Type application/json; charset=utf-8 + allow_duplicated_headers false + tls {{ if $ep.tls }}On{{ else }}Off{{ end }} + tls.verify {{ if .Values.dynatrace.ingest.tlsVerify }}On{{ else }}Off{{ end }} + Retry_Limit 5 +{{- if .Values.auditLogs.enabled }} + + # Audit records carry their own timestamp (the event_time the Lua filter copied), so + # Fluent Bit must not add one. + [OUTPUT] + Name http + Match audit.* + Host {{ $ep.host }} + Port {{ $ep.port }} + URI {{ $ep.basePath }}/api/v2/logs/ingest + Format json + json_date_key false + Header Authorization {{ .Values.dynatrace.ingest.authScheme }} ${DT_INGEST_TOKEN} + Header Content-Type application/json; charset=utf-8 + allow_duplicated_headers false + tls {{ if $ep.tls }}On{{ else }}Off{{ end }} + tls.verify {{ if .Values.dynatrace.ingest.tlsVerify }}On{{ else }}Off{{ end }} + Retry_Limit 5 +{{- end }} + + parsers.conf: | + [PARSER] + Name docker_no_time + Format json + Time_Keep Off + Time_Key time + Time_Format %Y-%m-%dT%H:%M:%S.%L +{{- if .Values.auditLogs.enabled }} + + [PARSER] + Name audit_json + Format json +{{- end }} + + # The Grail field names written here are read by the adapter + # (internal/dynatrace/fields.go). The two sides are one contract. + dynatrace.lua: | + local function set(out, key, value) + if type(value) == "string" and value ~= "" then + out[key] = value + end + end + + -- Kubernetes coordinates, named as in the Dynatrace semantic dictionary. + local function set_k8s(out, record) + set(out, "log.source", record["log.source"]) + set(out, "k8s.cluster.name", record["k8s.cluster.name"]) + local k8s = record["kubernetes"] + if type(k8s) ~= "table" then + return nil + end + set(out, "k8s.namespace.name", k8s["namespace_name"]) + set(out, "k8s.pod.name", k8s["pod_name"]) + set(out, "k8s.pod.uid", k8s["pod_id"]) + set(out, "k8s.container.name", k8s["container_name"]) + set(out, "k8s.node.name", k8s["host"]) + set(out, "k8s.pod.ip", k8s["pod_ip"]) + set(out, "container.image.name", k8s["container_image"]) + return k8s + end + + -- OpenChoreo pod labels get fields of their own, so the component and workflow log + -- queries filter on a plain field rather than searching the label array. + local openchoreo_labels = { + ["openchoreo.dev/namespace"] = "openchoreo.namespace", + ["openchoreo.dev/project"] = "openchoreo.project", + ["openchoreo.dev/project-uid"] = "openchoreo.project_uid", + ["openchoreo.dev/component"] = "openchoreo.component", + ["openchoreo.dev/component-uid"] = "openchoreo.component_uid", + ["openchoreo.dev/environment"] = "openchoreo.environment", + ["openchoreo.dev/environment-uid"] = "openchoreo.environment_uid", + ["workflows.argoproj.io/workflow"] = "openchoreo.workflow_run", + } + + function dt_container_record(tag, timestamp, record) + local out = {} + set(out, "content", record["log"]) + if out["content"] == nil then + out["content"] = "" + end + local k8s = set_k8s(out, record) + + if k8s ~= nil and type(k8s["labels"]) == "table" then + -- Every pod label as "key=value": keys keep the "/" and "." Kubernetes spells + -- them with, which field names could not carry unmangled. Sorted so a record's + -- labels always serialize the same way. + local labels = {} + for key, value in pairs(k8s["labels"]) do + if type(value) == "string" then + labels[#labels + 1] = key .. "=" .. value + local fieldName = openchoreo_labels[key] + if fieldName ~= nil then + set(out, fieldName, value) + end + end + end + if #labels > 0 then + table.sort(labels) + out["k8s.pod.labels"] = labels + end + end + + return 2, timestamp, out + end +{{- if .Values.auditLogs.enabled }} + + local audit_scalars = { + "schema_version", "event_id", "action", "category", "result", "request_id", + "source_ip", "user_agent", "producer", "surface", "operation_id", + } + local audit_actor = { "type", "id", "issuer", "session_id" } + local audit_resource = { "type", "namespace", "environment", "project", "component", "resource", "name" } + + -- The record is kept verbatim in content, and its filterable fields are lifted under + -- "audit." so DQL can filter on them. + function dt_audit_record(tag, timestamp, record) + local out = {} + set(out, "content", record["log"]) + set(out, "timestamp", record["event_time"]) + set_k8s(out, record) + + for _, key in ipairs(audit_scalars) do + set(out, "audit." .. key, record[key]) + end + + local actor = record["actor"] + if type(actor) == "table" then + for _, key in ipairs(audit_actor) do + set(out, "audit.actor." .. key, actor[key]) + end + -- Every value of every entitlement claim in one array, so a filter matches + -- whichever claim carries the value. + if type(actor["entitlements"]) == "table" then + local seen, values = {}, {} + for _, claim in pairs(actor["entitlements"]) do + if type(claim) == "table" then + for _, v in ipairs(claim) do + if type(v) == "string" and not seen[v] then + seen[v] = true + values[#values + 1] = v + end + end + end + end + if #values > 0 then + table.sort(values) + out["audit.actor.entitlements"] = values + end + end + end + + local resource = record["resource"] + if type(resource) == "table" then + for _, key in ipairs(audit_resource) do + set(out, "audit.resource." .. key, resource[key]) + end + end + + return 2, timestamp, out + end +{{- end }} +{{- end }} diff --git a/observability-logs-dynatrace/helm/templates/validate.yaml b/observability-logs-dynatrace/helm/templates/validate.yaml new file mode 100644 index 00000000..95abec8a --- /dev/null +++ b/observability-logs-dynatrace/helm/templates/validate.yaml @@ -0,0 +1,6 @@ +{{- /* +Copyright 2026 The OpenChoreo Authors +SPDX-License-Identifier: Apache-2.0 +*/ -}} + +{{- include "logs-dynatrace.validate" . -}} diff --git a/observability-logs-dynatrace/helm/values.yaml b/observability-logs-dynatrace/helm/values.yaml new file mode 100644 index 00000000..2fe0baf1 --- /dev/null +++ b/observability-logs-dynatrace/helm/values.yaml @@ -0,0 +1,193 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +dynatrace: + # Platform (Grail) API base URL of the environment the adapter queries, e.g. + # https://abc12345.apps.dynatrace.com. REQUIRED when adapter.enabled is true. + platformUrl: "" + + # Base URL of the environment's classic API, which serves log ingest, e.g. + # https://abc12345.live.dynatrace.com (or https://:9999/e/ through + # an ActiveGate). When empty it is derived from platformUrl by replacing ".apps." with + # ".live.". REQUIRED when fluent-bit.enabled is true and platformUrl is not set. + ingestUrl: "" + + ingest: + # Authorization scheme Fluent Bit sends the ingest token with: + # Api-Token - an access token with the logs.ingest scope (dt0c01.*) + # Bearer - a platform token or OAuth token with openpipeline:logs:ingest + authScheme: "Api-Token" + # Verify the ingest endpoint's TLS certificate. Leave on for Dynatrace SaaS. + tlsVerify: true + + # log.source stamped on every record, which scopes the adapter's queries to the records + # this module ships. Both must differ, and must match on every cluster reporting to one + # environment. + containerLogsSource: "openchoreo-container-logs" + auditLogsSource: "openchoreo-audit-logs" + + # Secret holding the Dynatrace credentials, in the release namespace. Create it with an + # ExternalSecret (recommended), or set create=true to have the chart create it from the + # values below for a quick trial. + credentials: + name: "dynatrace-credentials" + create: false + # Key names inside the Secret. + ingestTokenKey: "DT_INGEST_TOKEN" + platformTokenKey: "DT_PLATFORM_TOKEN" + oauthClientIdKey: "DT_OAUTH_CLIENT_ID" + oauthClientSecretKey: "DT_OAUTH_CLIENT_SECRET" + # Only read when create=true. Do not commit real tokens to a values file. + ingestToken: "" + platformToken: "" + oauthClientId: "" + oauthClientSecret: "" + +auditLogs: + # Route the OpenChoreo audit trail to its own log.source. Requires fluent-bit where + # openchoreo-api and observer run. + enabled: false + + # Each entry grants a workload write access to the audit trail. Update these if the + # control or observability plane runs in a non-default namespace. + producers: + - producer: openchoreo-api + namespace: openchoreo-control-plane + container: api-server + - producer: observer + namespace: openchoreo-observability-plane + container: observer + + # Grail bucket the audit records are routed to by an OpenPipeline bucket-assignment + # rule (see the README). When set, the adapter reads audit logs from this bucket only. + # Leave empty to read from every bucket the platform token can see. + bucket: "" + +fluent-bit: + enabled: true + + existingConfigMap: "fluent-bit" + + dnsPolicy: ClusterFirstWithHostNet + + env: + - name: DT_INGEST_TOKEN + valueFrom: + secretKeyRef: + name: dynatrace-credentials + key: DT_INGEST_TOKEN + + fullnameOverride: "fluent-bit" + + hostNetwork: true + + extraVolumeMounts: + - name: db + mountPath: "/var/lib/fluent-bit/db" + readOnly: false + + extraVolumes: + - name: db + hostPath: + path: /var/lib/fluent-bit/db + type: DirectoryOrCreate + + rbac: + nodeAccess: true + + resources: + limits: + cpu: 100m + memory: 150Mi + requests: + cpu: 25m + memory: 125Mi + + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10000 + + testFramework: + enabled: false + + initContainers: + - name: set-volume-ownership + image: busybox:1.36 + command: ["sh", "-c", "chown -R 10000:10000 /var/lib/fluent-bit/db"] + resources: + limits: + cpu: 15m + memory: "32Mi" + requests: + cpu: 10m + memory: "24Mi" + securityContext: + capabilities: + add: + - CHOWN + drop: + - ALL + readOnlyRootFilesystem: true + runAsUser: 0 + volumeMounts: + - name: db + mountPath: /var/lib/fluent-bit/db + +## ----------------------------------------------------------- +## Values for OpenChoreo specific customizations and workloads +## ----------------------------------------------------------- + +fluentBitCustomizations: + # Name of the cluster these records were collected from, stamped as k8s.cluster.name. + # REQUIRED when fluent-bit.enabled is true; it cannot be defaulted. Letters, digits, + # '.', '_' and '-' only. + clusterInstance: "" + + # Container log files that are not shipped. Fluent Bit's own logs are always excluded + # so a failing output cannot feed itself. + excludePaths: + - /var/log/containers/*_kube-system_*.log + + # Read container log files from the beginning on first start. Dynatrace rejects records + # older than 24 hours, so on a node with old logs the first flushes may be rejected + # until Fluent Bit catches up. + readFromHead: true + +adapter: + enabled: true + + # How the adapter authenticates to the Grail query API: + # platformToken - a platform token (dynatrace.credentials.platformTokenKey) + # oauth - an OAuth client (dynatrace.credentials.oauthClient*Key) + # Both need the storage:logs:read and storage:buckets:read scopes. + authMode: "platformToken" + + oauth: + tokenUrl: "https://sso.dynatrace.com/sso/oauth2/token" + scope: "storage:logs:read storage:buckets:read" + # Optional resource URN, e.g. urn:dtaccount:. + resource: "" + + # Upper bound for one Grail query, including polling. + queryTimeout: "30s" + + logLevel: "info" + + image: + repository: "ghcr.io/openchoreo/observability-logs-dynatrace-adapter" + tag: "" # Defaults to Chart.AppVersion via the template + pullPolicy: IfNotPresent + + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 50m + memory: 128Mi diff --git a/observability-logs-dynatrace/internal/api/cfg-models.yaml b/observability-logs-dynatrace/internal/api/cfg-models.yaml new file mode 100644 index 00000000..cb8418dd --- /dev/null +++ b/observability-logs-dynatrace/internal/api/cfg-models.yaml @@ -0,0 +1,4 @@ +package: gen +output: gen/models.gen.go +generate: + models: true diff --git a/observability-logs-dynatrace/internal/api/cfg-server.yaml b/observability-logs-dynatrace/internal/api/cfg-server.yaml new file mode 100644 index 00000000..59acbadc --- /dev/null +++ b/observability-logs-dynatrace/internal/api/cfg-server.yaml @@ -0,0 +1,6 @@ +package: gen +output: gen/server.gen.go +generate: + std-http-server: true + strict-server: true + embedded-spec: true diff --git a/observability-logs-dynatrace/internal/api/gen/models.gen.go b/observability-logs-dynatrace/internal/api/gen/models.gen.go new file mode 100644 index 00000000..aafdce90 --- /dev/null +++ b/observability-logs-dynatrace/internal/api/gen/models.gen.go @@ -0,0 +1,1310 @@ +// Package gen provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package gen + +import ( + "encoding/json" + "time" + + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Defines values for AlertRuleRequestConditionOperator. +const ( + AlertRuleRequestConditionOperatorEq AlertRuleRequestConditionOperator = "eq" + AlertRuleRequestConditionOperatorGt AlertRuleRequestConditionOperator = "gt" + AlertRuleRequestConditionOperatorGte AlertRuleRequestConditionOperator = "gte" + AlertRuleRequestConditionOperatorLt AlertRuleRequestConditionOperator = "lt" + AlertRuleRequestConditionOperatorLte AlertRuleRequestConditionOperator = "lte" + AlertRuleRequestConditionOperatorNeq AlertRuleRequestConditionOperator = "neq" +) + +// Defines values for AlertRuleResponseConditionOperator. +const ( + AlertRuleResponseConditionOperatorEq AlertRuleResponseConditionOperator = "eq" + AlertRuleResponseConditionOperatorGt AlertRuleResponseConditionOperator = "gt" + AlertRuleResponseConditionOperatorGte AlertRuleResponseConditionOperator = "gte" + AlertRuleResponseConditionOperatorLt AlertRuleResponseConditionOperator = "lt" + AlertRuleResponseConditionOperatorLte AlertRuleResponseConditionOperator = "lte" + AlertRuleResponseConditionOperatorNeq AlertRuleResponseConditionOperator = "neq" +) + +// Defines values for AlertRuleResponseSourceMetric. +const ( + CpuUsage AlertRuleResponseSourceMetric = "cpu_usage" + MemoryUsage AlertRuleResponseSourceMetric = "memory_usage" +) + +// Defines values for AlertWebhookResponseStatus. +const ( + AlertWebhookResponseStatusError AlertWebhookResponseStatus = "error" + AlertWebhookResponseStatusSuccess AlertWebhookResponseStatus = "success" +) + +// Defines values for AlertingRuleSyncResponseAction. +const ( + Created AlertingRuleSyncResponseAction = "created" + Deleted AlertingRuleSyncResponseAction = "deleted" + Unchanged AlertingRuleSyncResponseAction = "unchanged" + Updated AlertingRuleSyncResponseAction = "updated" +) + +// Defines values for AlertingRuleSyncResponseStatus. +const ( + Failed AlertingRuleSyncResponseStatus = "failed" + Synced AlertingRuleSyncResponseStatus = "synced" +) + +// Defines values for AuditLogFilterValuesRequestFilter. +const ( + Action AuditLogFilterValuesRequestFilter = "action" + ActorEntitlements AuditLogFilterValuesRequestFilter = "actor.entitlements" + ActorId AuditLogFilterValuesRequestFilter = "actor.id" + ActorIssuer AuditLogFilterValuesRequestFilter = "actor.issuer" + ActorSessionId AuditLogFilterValuesRequestFilter = "actor.session_id" + ActorType AuditLogFilterValuesRequestFilter = "actor.type" + Category AuditLogFilterValuesRequestFilter = "category" + OperationId AuditLogFilterValuesRequestFilter = "operation_id" + Producer AuditLogFilterValuesRequestFilter = "producer" + ResourceComponent AuditLogFilterValuesRequestFilter = "resource.component" + ResourceEnvironment AuditLogFilterValuesRequestFilter = "resource.environment" + ResourceName AuditLogFilterValuesRequestFilter = "resource.name" + ResourceNamespace AuditLogFilterValuesRequestFilter = "resource.namespace" + ResourceProject AuditLogFilterValuesRequestFilter = "resource.project" + ResourceResource AuditLogFilterValuesRequestFilter = "resource.resource" + ResourceType AuditLogFilterValuesRequestFilter = "resource.type" + Result AuditLogFilterValuesRequestFilter = "result" + SourceIp AuditLogFilterValuesRequestFilter = "source_ip" + Surface AuditLogFilterValuesRequestFilter = "surface" + UserAgent AuditLogFilterValuesRequestFilter = "user_agent" +) + +// Defines values for AuditLogsQueryRequestCategory. +const ( + Access AuditLogsQueryRequestCategory = "access" + Authorization AuditLogsQueryRequestCategory = "authorization" + Management AuditLogsQueryRequestCategory = "management" +) + +// Defines values for AuditLogsQueryRequestResult. +const ( + AuditLogsQueryRequestResultDenied AuditLogsQueryRequestResult = "denied" + AuditLogsQueryRequestResultFailure AuditLogsQueryRequestResult = "failure" + AuditLogsQueryRequestResultSuccess AuditLogsQueryRequestResult = "success" +) + +// Defines values for AuditLogsQueryRequestSortOrder. +const ( + AuditLogsQueryRequestSortOrderAsc AuditLogsQueryRequestSortOrder = "asc" + AuditLogsQueryRequestSortOrderDesc AuditLogsQueryRequestSortOrder = "desc" +) + +// Defines values for AuditLogsQueryRequestSurface. +const ( + Mcp AuditLogsQueryRequestSurface = "mcp" + Rest AuditLogsQueryRequestSurface = "rest" +) + +// Defines values for ErrorResponseTitle. +const ( + BadRequest ErrorResponseTitle = "badRequest" + Conflict ErrorResponseTitle = "conflict" + Forbidden ErrorResponseTitle = "forbidden" + Gone ErrorResponseTitle = "gone" + InternalServerError ErrorResponseTitle = "internalServerError" + NotFound ErrorResponseTitle = "notFound" + NotImplemented ErrorResponseTitle = "notImplemented" + Unauthorized ErrorResponseTitle = "unauthorized" +) + +// Defines values for EventsQueryRequestSortOrder. +const ( + EventsQueryRequestSortOrderAsc EventsQueryRequestSortOrder = "asc" + EventsQueryRequestSortOrderDesc EventsQueryRequestSortOrder = "desc" +) + +// Defines values for LogsQueryRequestLogLevels. +const ( + LogsQueryRequestLogLevelsDEBUG LogsQueryRequestLogLevels = "DEBUG" + LogsQueryRequestLogLevelsERROR LogsQueryRequestLogLevels = "ERROR" + LogsQueryRequestLogLevelsINFO LogsQueryRequestLogLevels = "INFO" + LogsQueryRequestLogLevelsWARN LogsQueryRequestLogLevels = "WARN" +) + +// Defines values for LogsQueryRequestSortOrder. +const ( + LogsQueryRequestSortOrderAsc LogsQueryRequestSortOrder = "asc" + LogsQueryRequestSortOrderDesc LogsQueryRequestSortOrder = "desc" +) + +// Defines values for PlatformLogFilterValuesRequestFilter. +const ( + ClusterInstance PlatformLogFilterValuesRequestFilter = "clusterInstance" + ContainerName PlatformLogFilterValuesRequestFilter = "containerName" + Namespace PlatformLogFilterValuesRequestFilter = "namespace" + PodName PlatformLogFilterValuesRequestFilter = "podName" +) + +// Defines values for PlatformLogsQueryRequestLogLevels. +const ( + PlatformLogsQueryRequestLogLevelsDEBUG PlatformLogsQueryRequestLogLevels = "DEBUG" + PlatformLogsQueryRequestLogLevelsERROR PlatformLogsQueryRequestLogLevels = "ERROR" + PlatformLogsQueryRequestLogLevelsINFO PlatformLogsQueryRequestLogLevels = "INFO" + PlatformLogsQueryRequestLogLevelsWARN PlatformLogsQueryRequestLogLevels = "WARN" +) + +// Defines values for PlatformLogsQueryRequestSortOrder. +const ( + PlatformLogsQueryRequestSortOrderAsc PlatformLogsQueryRequestSortOrder = "asc" + PlatformLogsQueryRequestSortOrderDesc PlatformLogsQueryRequestSortOrder = "desc" +) + +// AlertRuleRequest defines model for AlertRuleRequest. +type AlertRuleRequest struct { + Condition struct { + // Enabled Whether the alert rule is enabled + Enabled bool `json:"enabled"` + + // Interval The interval of time to query for the alert rule + Interval string `json:"interval"` + + // Operator The operator to use for the alert rule + Operator AlertRuleRequestConditionOperator `json:"operator"` + + // Threshold The threshold value to use for the alert rule + Threshold float32 `json:"threshold"` + + // Window The window of time to query for the alert rule + Window string `json:"window"` + } `json:"condition"` + Metadata struct { + // ComponentUid The OpenChoreo component UID to query + ComponentUid openapi_types.UUID `json:"componentUid"` + + // EnvironmentUid The OpenChoreo environment UID to query + EnvironmentUid openapi_types.UUID `json:"environmentUid"` + + // Name The name of the alert rule + Name string `json:"name"` + + // Namespace The namespace of the alert rule CR + Namespace string `json:"namespace"` + + // ProjectUid The OpenChoreo project UID to query + ProjectUid openapi_types.UUID `json:"projectUid"` + } `json:"metadata"` + Source struct { + // Query The query to execute for log based alerts + Query string `json:"query"` + } `json:"source"` +} + +// AlertRuleRequestConditionOperator The operator to use for the alert rule +type AlertRuleRequestConditionOperator string + +// AlertRuleResponse defines model for AlertRuleResponse. +type AlertRuleResponse struct { + Condition *struct { + // Enabled Whether the alert rule is enabled + Enabled *bool `json:"enabled,omitempty"` + + // Interval The interval of time to query for the alert rule + Interval *string `json:"interval,omitempty"` + + // Operator The operator to use for the alert rule + Operator *AlertRuleResponseConditionOperator `json:"operator,omitempty"` + + // Threshold The threshold value to use for the alert rule + Threshold *float32 `json:"threshold,omitempty"` + + // Window The window of time to query for the alert rule + Window *string `json:"window,omitempty"` + } `json:"condition,omitempty"` + Metadata *struct { + // ComponentUid The OpenChoreo component UID to query + ComponentUid *openapi_types.UUID `json:"componentUid,omitempty"` + + // EnvironmentUid The OpenChoreo environment UID to query + EnvironmentUid *openapi_types.UUID `json:"environmentUid,omitempty"` + + // Name The name of the alert rule + Name *string `json:"name,omitempty"` + + // Namespace The namespace of the alert rule CR + Namespace *string `json:"namespace,omitempty"` + + // ProjectUid The OpenChoreo project UID to query + ProjectUid *openapi_types.UUID `json:"projectUid,omitempty"` + } `json:"metadata,omitempty"` + Source *struct { + // Metric The metric to query for metric based alerts + Metric *AlertRuleResponseSourceMetric `json:"metric,omitempty"` + + // Query The query to execute for log based alerts + Query *string `json:"query,omitempty"` + } `json:"source,omitempty"` +} + +// AlertRuleResponseConditionOperator The operator to use for the alert rule +type AlertRuleResponseConditionOperator string + +// AlertRuleResponseSourceMetric The metric to query for metric based alerts +type AlertRuleResponseSourceMetric string + +// AlertWebhookResponse defines model for AlertWebhookResponse. +type AlertWebhookResponse struct { + // Message The message of the alert webhook + Message *string `json:"message,omitempty"` + + // Status The status of the alert webhook + Status *AlertWebhookResponseStatus `json:"status,omitempty"` +} + +// AlertWebhookResponseStatus The status of the alert webhook +type AlertWebhookResponseStatus string + +// AlertingRuleSyncResponse defines model for AlertingRuleSyncResponse. +type AlertingRuleSyncResponse struct { + // Action The action taken on the alert rule + Action *AlertingRuleSyncResponseAction `json:"action,omitempty"` + + // LastSyncedAt The timestamp of the last sync + LastSyncedAt *string `json:"lastSyncedAt,omitempty"` + + // RuleBackendId The backend ID (UID from observability backend) of the alert rule + RuleBackendId *string `json:"ruleBackendId,omitempty"` + + // RuleLogicalId The logical ID (name) of the alert rule + RuleLogicalId *string `json:"ruleLogicalId,omitempty"` + + // Status The status of the alert rule + Status *AlertingRuleSyncResponseStatus `json:"status,omitempty"` +} + +// AlertingRuleSyncResponseAction The action taken on the alert rule +type AlertingRuleSyncResponseAction string + +// AlertingRuleSyncResponseStatus The status of the alert rule +type AlertingRuleSyncResponseStatus string + +// AuditLogActor Who performed the action. `id` is unique only within `issuer` — the same `sub` +// from two identity providers is two different subjects. +type AuditLogActor struct { + Entitlements *map[string][]string `json:"entitlements,omitempty"` + + // Id A validated token claim: the one named by the subject's own auth mechanism + // (`readable_id_claim`), else the producer's `audit.actor.id_claim`, else `sub`. + // `unknown` when the token carries none of them, or `anonymous`. + // + // Because the claim is resolved per subject type, two actors of different + // types may have their `id` drawn from different claims + Id string `json:"id"` + + // Issuer The token's `iss` claim; the namespace `id` is unique within + Issuer *string `json:"issuer,omitempty"` + + // SessionId The token's `sid` claim, joining this event to an identity-provider login + SessionId *string `json:"session_id,omitempty"` + + // Type e.g. `user`, `service_account`, `anonymous` + Type string `json:"type"` +} + +// AuditLogCollectorInfo Where the record was collected from, as stamped by the collector rather than by +// the emitting service. +// +// Returned so a caller can compare it against `producer` — a record whose claimed +// origin and collected origin disagree is worth looking at. Neither this adapter +// nor the observer performs that comparison; both values are carried and the +// judgement is left to whoever is investigating. +type AuditLogCollectorInfo struct { + ContainerName *string `json:"containerName,omitempty"` + NamespaceName *string `json:"namespaceName,omitempty"` + PodName *string `json:"podName,omitempty"` +} + +// AuditLogFilterValue One value a filter takes, with how many records carry it. +type AuditLogFilterValue struct { + // Count Matching records carrying this value. May be approximate on a + // high-cardinality filter answered from a partial term count, so it is an + // ordering hint and a sense of scale rather than a total. + Count int64 `json:"count"` + + // Value The value, exactly as it would be sent back as a filter + Value string `json:"value"` +} + +// AuditLogFilterValuesRequest Which filter to list values for, and the query to list them under. +// +// **The time window lives in `query`, and it is required** — `startTime` and +// `endTime` are mandatory on `AuditLogsQueryRequest`, so every call is scoped to +// a period and there is no way to ask for distinct values across all of +// retention. +// +// The other filters in `query` narrow which records the values are drawn from, +// except the one named by `filter`, whose own selections are ignored. +type AuditLogFilterValuesRequest struct { + // Filter The filter to list values for, named by its path in + // `AuditLogsQueryRequest`. + // + // `event_id` and `request_id` are absent: near-unique per record, so a list + // of them is not something a caller picks from. `actor.session_id` is present + // because `valueSearch` makes it reachable. + Filter AuditLogFilterValuesRequestFilter `json:"filter"` + + // MaxValues Maximum values to return, ordered by `count` descending then `value` + // ascending, so a truncated list holds the busiest. Named to stay distinct + // from `query.limit`, which is a record page size and is ignored here. + MaxValues *int `json:"maxValues,omitempty"` + + // Query A filter set over the audit trail, shaped like the record it filters. Every + // filter is named and nested exactly as the field it matches in `AuditLogRecord`, + // so an adapter maps a filter onto a stored field without a lookup table. + // + // Record-derived filters keep the record's snake_case spelling (`operation_id`, + // `source_ip`); the query's own controls stay camelCase (`startTime`, `limit`, + // `includeTimeline`). The casing marks which of the two a field is. + // + // Multi-value fields OR within a field; fields AND with each other. An absent + // field is not a filter. + // + // The tenancy filters under `resource` filter the record's own `resource` group. + // They are filters, not scopes — the observer authorizes the query at cluster + // scope before it reaches this adapter, so an adapter must not treat them as a + // permission. + // + // `resource.uid` has no filter; see the observer spec for why. + Query AuditLogsQueryRequest `json:"query"` + + // ValueSearch Return only values containing this text, case-insensitively. Narrows the + // **values** returned, unlike `query.searchPhrase`, which narrows the records + // considered. + ValueSearch *string `json:"valueSearch,omitempty"` +} + +// AuditLogFilterValuesRequestFilter The filter to list values for, named by its path in +// `AuditLogsQueryRequest`. +// +// `event_id` and `request_id` are absent: near-unique per record, so a list +// of them is not something a caller picks from. `actor.session_id` is present +// because `valueSearch` makes it reachable. +type AuditLogFilterValuesRequestFilter string + +// AuditLogFilterValuesResponse defines model for AuditLogFilterValuesResponse. +type AuditLogFilterValuesResponse struct { + // Filter The filter these values belong to, echoed from the request + Filter string `json:"filter"` + + // TookMs The time taken to compute the values in milliseconds + TookMs int64 `json:"tookMs"` + + // TotalValues How many distinct values match, of which at most `maxValues` were returned. + TotalValues int64 `json:"totalValues"` + + // Values Distinct values, ordered by `count` descending then `value` ascending. + // Records on which the field is absent are not represented: no empty-string + // entry, because no filter value would select one. + Values []AuditLogFilterValue `json:"values"` +} + +// AuditLogHTTPInfo The request line, for an event that arrived over HTTP. Absent for an MCP +// `tools/call`, which has none. +type AuditLogHTTPInfo struct { + Method *string `json:"method,omitempty"` + + // Path Route path only; query strings are not recorded + Path *string `json:"path,omitempty"` +} + +// AuditLogRecord One audit event, in the field names and nesting it was published with. Keys are +// snake_case here while the surrounding envelope is camelCase, and that is +// deliberate: this is the frozen, versioned record a SIEM already consumes, so a +// response body can be compared against an exported log line key for key rather +// than through a translation table. +// +// `category` and `result` are plain strings rather than enums, unlike their +// request-side counterparts: a filter is a closed input the server validates, +// while a record's vocabulary grows with `schema_version`, and a closed enum here +// would make an older client reject a newer record. +type AuditLogRecord struct { + // Action Semantic action name, e.g. `create_project` + Action string `json:"action"` + + // Actor Who performed the action. `id` is unique only within `issuer` — the same `sub` + // from two identity providers is two different subjects. + Actor AuditLogActor `json:"actor"` + + // Category Event category. `management`, `authorization` or `access` at schema 1.0. + Category string `json:"category"` + + // Collector Where the record was collected from, as stamped by the collector rather than by + // the emitting service. + // + // Returned so a caller can compare it against `producer` — a record whose claimed + // origin and collected origin disagree is worth looking at. Neither this adapter + // nor the observer performs that comparison; both values are carried and the + // judgement is left to whoever is investigating. + Collector *AuditLogCollectorInfo `json:"collector,omitempty"` + + // EventId UUID v7, unique per record + EventId string `json:"event_id"` + + // EventTime When the audited request was received + EventTime time.Time `json:"event_time"` + + // Http The request line, for an event that arrived over HTTP. Absent for an MCP + // `tools/call`, which has none. + Http *AuditLogHTTPInfo `json:"http,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + + // OperationId Canonical operation identifier, e.g. `CreateProject` + OperationId *string `json:"operation_id,omitempty"` + + // Producer Emitting service, e.g. `openchoreo-api` + Producer *string `json:"producer,omitempty"` + + // RequestId Correlates this record with the access log line for the same request + RequestId *string `json:"request_id,omitempty"` + + // Resource The target resource, and the point in OpenChoreo's tree the decision was + // authorized at. Absent on a rejection that resolved no operation. + Resource *AuditLogResource `json:"resource,omitempty"` + + // Result Outcome. `success`, `failure` or `denied` (an authenticated subject refused by + // policy) at schema 1.0. + Result string `json:"result"` + + // SchemaVersion Schema of this record. `major.minor`; major on a field removal or a changed value representation, minor on an addition. + SchemaVersion string `json:"schema_version"` + SourceIp *string `json:"source_ip,omitempty"` + + // Surface Which surface of the API the call arrived through. `rest` or `mcp` at + // schema 1.0 — MCP wraps the same API, so the REST value is not `api`. + Surface *string `json:"surface,omitempty"` + + // UserAgent Client-supplied and unverifiable, like `source_ip`. The only field that + // separates a portal session from occ, CI or an agent. + UserAgent *string `json:"user_agent,omitempty"` +} + +// AuditLogResource The target resource, and the point in OpenChoreo's tree the decision was +// authorized at. Absent on a rejection that resolved no operation. +type AuditLogResource struct { + Component *string `json:"component,omitempty"` + + // Environment Dual-scoped `{namespace}/{name}`, as authorization evaluated it + Environment *string `json:"environment,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Name *string `json:"name,omitempty"` + Namespace *string `json:"namespace,omitempty"` + Project *string `json:"project,omitempty"` + Resource *string `json:"resource,omitempty"` + Type *string `json:"type,omitempty"` + + // Uid Server-generated identifier that is never reused. Absent when the operation + // returned no object — a delete, or a non-CRUD mutation. + Uid *string `json:"uid,omitempty"` +} + +// AuditLogTimeline Per-interval counts across the queried window, broken down by `result`. Returned +// only when the request set `includeTimeline`, and omitted rather than empty when +// this adapter cannot compute it. +type AuditLogTimeline struct { + // Buckets One entry per interval, in ascending `startTime` order regardless of the + // query's `sortOrder`. + // + // **Buckets with no records are present with zero counts, not omitted.** The + // array covers the window from `startTime` to `endTime` contiguously; a + // sparse array would let a caller draw a continuous chart across a gap in + // activity. The final bucket may be shorter than `interval` where the window + // does not divide evenly. + Buckets []AuditLogTimelineBucket `json:"buckets"` + + // Interval The bucket width actually used, which is not necessarily the requested + // `timelineInterval` — a width exceeding 500 buckets is coarsened. + Interval string `json:"interval"` +} + +// AuditLogTimelineBucket One interval of the timeline. +type AuditLogTimelineBucket struct { + // Counts Records in this bucket by `result`, keyed by the value itself. An open map + // so a `result` value added in a later schema needs no spec bump. + // + // **A result with no records in this bucket may be omitted, and an absent key + // means zero.** + Counts *map[string]int64 `json:"counts,omitempty"` + + // StartTime Inclusive lower bound of the bucket; its width is `interval` + StartTime time.Time `json:"startTime"` + + // Total Records in this bucket. Equals the sum of `counts`, carried separately so a + // bucket whose breakdown could not be produced still reports a height. + Total int64 `json:"total"` +} + +// AuditLogsActorFilter Filters on the record's `actor` group, named and nested as the record is. +type AuditLogsActorFilter struct { + // Entitlements Entitlement values, matched against the values of **every** claim in the + // record's `actor.entitlements` map rather than one named claim — the claim + // key varies by subject kind (`groups` for a user, `sub` for a service + // account) and a caller should not have to know which. + Entitlements *[]string `json:"entitlements,omitempty"` + + // Id Subject identifiers. Unique only within `issuer`, so on a multi-issuer + // deployment an `id` filter is meaningful only paired with one. + Id *[]string `json:"id,omitempty"` + + // Issuer Token issuers — the namespace an `id` is unique within + Issuer *[]string `json:"issuer,omitempty"` + + // SessionId Identity-provider session identifiers, from the token's `sid` claim. Absent + // on client-credentials tokens, so a session filter excludes service accounts. + SessionId *[]string `json:"session_id,omitempty"` + + // Type Kinds of subject, e.g. `user`, `service_account`, `anonymous` + Type *[]string `json:"type,omitempty"` +} + +// AuditLogsQueryRequest A filter set over the audit trail, shaped like the record it filters. Every +// filter is named and nested exactly as the field it matches in `AuditLogRecord`, +// so an adapter maps a filter onto a stored field without a lookup table. +// +// Record-derived filters keep the record's snake_case spelling (`operation_id`, +// `source_ip`); the query's own controls stay camelCase (`startTime`, `limit`, +// `includeTimeline`). The casing marks which of the two a field is. +// +// Multi-value fields OR within a field; fields AND with each other. An absent +// field is not a filter. +// +// The tenancy filters under `resource` filter the record's own `resource` group. +// They are filters, not scopes — the observer authorizes the query at cluster +// scope before it reaches this adapter, so an adapter must not treat them as a +// permission. +// +// `resource.uid` has no filter; see the observer spec for why. +type AuditLogsQueryRequest struct { + // Action Semantic action names, e.g. `create_project` + Action *[]string `json:"action,omitempty"` + + // Actor Filters on the record's `actor` group, named and nested as the record is. + Actor *AuditLogsActorFilter `json:"actor,omitempty"` + + // Category Event categories. A closed set: the observer rejects an unknown value with + // a `400` rather than forwarding a filter that would silently match nothing. + // + // `access` covers reads that disclose without changing anything — reading the + // trail itself is recorded under it. + Category *[]AuditLogsQueryRequestCategory `json:"category,omitempty"` + + // EndTime Exclusive upper bound of the event window + EndTime time.Time `json:"endTime"` + + // EventId Record identifiers, matched exactly + EventId *[]string `json:"event_id,omitempty"` + + // IncludeTimeline Also return per-interval counts across the queried window, broken down by + // `result`. Costs one aggregation pass on top of the search, which is why it + // is opt-in; the observer requests it only for a first page. + // + // An adapter that cannot compute it answers normally and omits `timeline` + // rather than failing the query. + // + // This is the only aggregation on this operation. Per-filter distinct values + // are deliberately not requested here — one aggregation per filter rather + // than one in total is enough load to matter on a busy trail. They have their + // own operation, `POST /api/v1alpha1/audit-logs/filter-values`. + IncludeTimeline *bool `json:"includeTimeline,omitempty"` + + // Limit The maximum number of records to return + Limit *int `json:"limit,omitempty"` + + // OperationId Canonical operation identifiers, e.g. `CreateProject` + OperationId *[]string `json:"operation_id,omitempty"` + + // Producer Emitting services, e.g. `openchoreo-api` + Producer *[]string `json:"producer,omitempty"` + + // RequestId Correlation IDs shared with the access log, matched exactly + RequestId *[]string `json:"request_id,omitempty"` + + // Resource Filters on the record's `resource` group, named and nested as the record is. + // Filters, not scopes — see `AuditLogsQueryRequest`. + Resource *AuditLogsResourceFilter `json:"resource,omitempty"` + + // Result Outcomes. Closed, for the same reason as `category`. + Result *[]AuditLogsQueryRequestResult `json:"result,omitempty"` + + // SearchPhrase Free text to match within the record + SearchPhrase *string `json:"searchPhrase,omitempty"` + + // SortOrder Sort direction on the event time + SortOrder *AuditLogsQueryRequestSortOrder `json:"sortOrder,omitempty"` + + // SourceIp Client addresses, matched exactly rather than by network range + SourceIp *[]string `json:"source_ip,omitempty"` + + // StartTime Inclusive lower bound of the event window + StartTime time.Time `json:"startTime"` + + // Surface Surfaces of the API the call arrived through. Closed. MCP wraps the same + // API, so the REST value is `rest` rather than `api`. + Surface *[]AuditLogsQueryRequestSurface `json:"surface,omitempty"` + + // TimelineInterval Requested bucket width for `timeline`, in `` notation where + // unit is one of `m`, `h`, `d` or `w`. Ignored unless `includeTimeline` is + // true; when omitted the adapter chooses a width from the window. + // + // A width that would produce more than 500 buckets must be **coarsened, not + // rejected**, and the width actually used reported in `timeline.interval`. + TimelineInterval *string `json:"timelineInterval,omitempty"` + + // UserAgent Client identifications, matched exactly + UserAgent *[]string `json:"user_agent,omitempty"` +} + +// AuditLogsQueryRequestCategory defines model for AuditLogsQueryRequest.Category. +type AuditLogsQueryRequestCategory string + +// AuditLogsQueryRequestResult defines model for AuditLogsQueryRequest.Result. +type AuditLogsQueryRequestResult string + +// AuditLogsQueryRequestSortOrder Sort direction on the event time +type AuditLogsQueryRequestSortOrder string + +// AuditLogsQueryRequestSurface defines model for AuditLogsQueryRequest.Surface. +type AuditLogsQueryRequestSurface string + +// AuditLogsResourceFilter Filters on the record's `resource` group, named and nested as the record is. +// Filters, not scopes — see `AuditLogsQueryRequest`. +type AuditLogsResourceFilter struct { + Component *[]string `json:"component,omitempty"` + + // Environment Environments in the dual-scoped `{namespace}/{name}` form, because that is + // how the value is stored — recorded exactly as authorization evaluated it. + // A bare name will not match. + Environment *[]string `json:"environment,omitempty"` + + // Name Resource names, as the handler recorded them + Name *[]string `json:"name,omitempty"` + + // Namespace OpenChoreo namespaces + Namespace *[]string `json:"namespace,omitempty"` + Project *[]string `json:"project,omitempty"` + Resource *[]string `json:"resource,omitempty"` + + // Type Resource kinds + Type *[]string `json:"type,omitempty"` +} + +// AuditLogsResponse defines model for AuditLogsResponse. +type AuditLogsResponse struct { + // Records Audit records matching the query, in `sortOrder` of `event_time` + Records []AuditLogRecord `json:"records"` + + // Timeline Per-interval counts across the queried window, broken down by `result`. Returned + // only when the request set `includeTimeline`, and omitted rather than empty when + // this adapter cannot compute it. + Timeline *AuditLogTimeline `json:"timeline,omitempty"` + + // TookMs The time taken to query the audit logs in milliseconds + TookMs int64 `json:"tookMs"` + + // Total Exact number of records matching the query across the whole window, not the + // number returned — `records` holds at most `limit`. A backend that caps hit + // counting by default must be configured to count fully: an audit consumer + // reading an understated total draws the wrong conclusion about how much + // happened. + Total int64 `json:"total"` +} + +// ComponentLogEntry defines model for ComponentLogEntry. +type ComponentLogEntry struct { + // Level The log level + Level *string `json:"level,omitempty"` + + // Log The log message + Log *string `json:"log,omitempty"` + + // Metadata The metadata of the log entry + Metadata *struct { + // ComponentName The OpenChoreo component name that generated the log + ComponentName *string `json:"componentName,omitempty"` + + // ComponentUid The OpenChoreo component UID that generated the log + ComponentUid *openapi_types.UUID `json:"componentUid,omitempty"` + + // ContainerName The container name that generated the log + ContainerName *string `json:"containerName,omitempty"` + + // EnvironmentName The OpenChoreo environment name that generated the log + EnvironmentName *string `json:"environmentName,omitempty"` + + // EnvironmentUid The OpenChoreo environment UID that generated the log + EnvironmentUid *openapi_types.UUID `json:"environmentUid,omitempty"` + + // NamespaceName The OpenChoreo namespace name that generated the log + NamespaceName *string `json:"namespaceName,omitempty"` + + // PodName The Kubernetes pod name that generated the log + PodName *string `json:"podName,omitempty"` + + // PodNamespace The namespace of the Kubernetes pod that generated the log + PodNamespace *string `json:"podNamespace,omitempty"` + + // ProjectName The OpenChoreo project name that generated the log + ProjectName *string `json:"projectName,omitempty"` + + // ProjectUid The OpenChoreo project UID that generated the log + ProjectUid *openapi_types.UUID `json:"projectUid,omitempty"` + } `json:"metadata,omitempty"` + + // Timestamp The timestamp of the log entry + Timestamp *time.Time `json:"timestamp,omitempty"` +} + +// ComponentSearchScope defines model for ComponentSearchScope. +type ComponentSearchScope struct { + ComponentUid *string `json:"componentUid,omitempty"` + EnvironmentUid *string `json:"environmentUid,omitempty"` + Namespace string `json:"namespace"` + ProjectUid *string `json:"projectUid,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // ErrorCode The error code from observer service + ErrorCode *string `json:"errorCode,omitempty"` + + // Message Human-readable error message + Message *string `json:"message,omitempty"` + + // Title The error message + Title *ErrorResponseTitle `json:"title,omitempty"` +} + +// ErrorResponseTitle The error message +type ErrorResponseTitle string + +// EventEntry defines model for EventEntry. +type EventEntry struct { + // Message The event message + Message *string `json:"message,omitempty"` + + // Metadata The metadata of the event + Metadata *struct { + // ComponentName The OpenChoreo component name the event is associated with + ComponentName *string `json:"componentName,omitempty"` + + // ComponentUid The OpenChoreo component UID the event is associated with + ComponentUid *openapi_types.UUID `json:"componentUid,omitempty"` + + // EnvironmentName The OpenChoreo environment name the event is associated with + EnvironmentName *string `json:"environmentName,omitempty"` + + // EnvironmentUid The OpenChoreo environment UID the event is associated with + EnvironmentUid *openapi_types.UUID `json:"environmentUid,omitempty"` + + // NamespaceName The OpenChoreo namespace name the event is associated with + NamespaceName *string `json:"namespaceName,omitempty"` + + // ObjectKind The kind of the Kubernetes object the event involves (e.g. CronJob) + ObjectKind *string `json:"objectKind,omitempty"` + + // ObjectName The name of the Kubernetes object the event involves + ObjectName *string `json:"objectName,omitempty"` + + // ObjectNamespace The namespace of the Kubernetes object the event involves + ObjectNamespace *string `json:"objectNamespace,omitempty"` + + // ProjectName The OpenChoreo project name the event is associated with + ProjectName *string `json:"projectName,omitempty"` + + // ProjectUid The OpenChoreo project UID the event is associated with + ProjectUid *openapi_types.UUID `json:"projectUid,omitempty"` + } `json:"metadata,omitempty"` + + // Reason The short, machine-readable reason for the event (e.g. SawCompletedJob) + Reason *string `json:"reason,omitempty"` + + // Timestamp The timestamp of the event + Timestamp *time.Time `json:"timestamp,omitempty"` + + // Type The event type (e.g. Normal, Warning) + Type *string `json:"type,omitempty"` +} + +// EventsQueryRequest defines model for EventsQueryRequest. +type EventsQueryRequest struct { + // EndTime The end time of the query + EndTime time.Time `json:"endTime"` + + // Limit The maximum number of items to return. This is a soft cap at the page boundary: an adapter MUST NOT split events sharing a single timestamp across pages, so a page that would otherwise end mid-timestamp is extended to include every event bearing that timestamp, even where the result exceeds `limit`. That is what lets a caller resume by timestamp without stalling; see the rules on `queryEvents`. + Limit *int `json:"limit,omitempty"` + + // Reasons Optional server-side filter on the event reason field. The adapter returns only events whose reason exactly matches one of the supplied values. Used by machine consumers such as the delivery insights aggregator to sweep specific controller-emitted events (e.g. DeploymentSucceeded). The list is bounded so that the filter stays cheap for the adapter to evaluate. + Reasons *[]string `json:"reasons,omitempty"` + + // SearchScope Scope of the query. Omitting it requests an unscoped sweep, which is permitted only together with `reasons` (machine consumers reading controller-emitted events across all namespaces). Interactive queries must always be scoped. Unscoped sweeps are an OPTIONAL adapter capability; see the queryEvents description for the rules that apply. + SearchScope *EventsQueryRequest_SearchScope `json:"searchScope,omitempty"` + + // SortOrder The sort order of the query + SortOrder *EventsQueryRequestSortOrder `json:"sortOrder,omitempty"` + + // StartTime The start time of the query + StartTime time.Time `json:"startTime"` +} + +// EventsQueryRequest_SearchScope Scope of the query. Omitting it requests an unscoped sweep, which is permitted only together with `reasons` (machine consumers reading controller-emitted events across all namespaces). Interactive queries must always be scoped. Unscoped sweeps are an OPTIONAL adapter capability; see the queryEvents description for the rules that apply. +type EventsQueryRequest_SearchScope struct { + union json.RawMessage +} + +// EventsQueryRequestSortOrder The sort order of the query +type EventsQueryRequestSortOrder string + +// EventsQueryResponse defines model for EventsQueryResponse. +type EventsQueryResponse struct { + // Events The events queried successfully + Events *[]EventEntry `json:"events,omitempty"` + + // TookMs The time taken to query the events in milliseconds + TookMs *int `json:"tookMs,omitempty"` + + // Total How many events matched the query, independent of `limit`. + // + // This is how a caller learns whether it read the whole window: `total` equal to the number of returned events means it did, and a greater value means the read stopped short and must be resumed. The adapter MUST therefore count at least as far as `limit + 1`, so that "exactly a full page" and "more than a page" are distinguishable. A backend that caps its match counting must raise that cap above `limit` -- OpenSearch stops at 10000 by default and needs `track_total_hits` set accordingly. + // + // An adapter that cannot count that far cannot report whether a sweep was truncated, and so cannot serve one safely: it MUST reject an unscoped request with 501 rather than return a total it cannot stand behind. Understating `total` makes a caller treat a truncated read as complete and advance past events it never saw. + Total int `json:"total"` +} + +// LogsQueryRequest defines model for LogsQueryRequest. +type LogsQueryRequest struct { + // EndTime The end time of the query + EndTime time.Time `json:"endTime"` + + // Limit The maximum number of items to return + Limit *int `json:"limit,omitempty"` + LogLevels *[]LogsQueryRequestLogLevels `json:"logLevels,omitempty"` + SearchPhrase *string `json:"searchPhrase,omitempty"` + SearchScope LogsQueryRequest_SearchScope `json:"searchScope"` + + // SortOrder The sort order of the query + SortOrder *LogsQueryRequestSortOrder `json:"sortOrder,omitempty"` + + // StartTime The start time of the query + StartTime time.Time `json:"startTime"` +} + +// LogsQueryRequestLogLevels defines model for LogsQueryRequest.LogLevels. +type LogsQueryRequestLogLevels string + +// LogsQueryRequest_SearchScope defines model for LogsQueryRequest.SearchScope. +type LogsQueryRequest_SearchScope struct { + union json.RawMessage +} + +// LogsQueryRequestSortOrder The sort order of the query +type LogsQueryRequestSortOrder string + +// LogsQueryResponse defines model for LogsQueryResponse. +type LogsQueryResponse struct { + // Logs The logs queried successfully + Logs *LogsQueryResponse_Logs `json:"logs,omitempty"` + + // TookMs The time taken to query the logs in milliseconds + TookMs *int `json:"tookMs,omitempty"` + + // Total The total number of matching log entries, capped at 1000 + Total *int `json:"total,omitempty"` +} + +// LogsQueryResponseLogs0 defines model for . +type LogsQueryResponseLogs0 = []ComponentLogEntry + +// LogsQueryResponseLogs1 defines model for . +type LogsQueryResponseLogs1 = []WorkflowLogEntry + +// LogsQueryResponse_Logs The logs queried successfully +type LogsQueryResponse_Logs struct { + union json.RawMessage +} + +// PlatformLog defines model for PlatformLog. +type PlatformLog struct { + ClusterInstance *string `json:"clusterInstance,omitempty"` + ContainerImage *string `json:"containerImage,omitempty"` + ContainerName *string `json:"containerName,omitempty"` + + // Labels Pod labels carried on the record, as the backend stores them, with any + // backend-specific key mangling already undone by the adapter. + Labels *map[string]string `json:"labels,omitempty"` + + // Level Log severity. Derived from the message text where the backend does not supply + // one; omitted when it cannot be determined. + Level *string `json:"level,omitempty"` + + // Log The log message + Log string `json:"log"` + NamespaceName *string `json:"namespaceName,omitempty"` + + // NodeName Node the pod was scheduled on. + NodeName *string `json:"nodeName,omitempty"` + PodIp *string `json:"podIp,omitempty"` + PodName *string `json:"podName,omitempty"` + + // Timestamp The timestamp of the log entry + Timestamp time.Time `json:"timestamp"` +} + +// PlatformLogFilterValue One value a filter takes, with how many records carry it. +type PlatformLogFilterValue struct { + // Count Matching records carrying this value. May be approximate on a + // high-cardinality filter where the backend answers from a partial term + // count, so it is an ordering hint rather than a total. + Count int64 `json:"count"` + + // Value The value, exactly as it would be sent back as a filter + Value string `json:"value"` +} + +// PlatformLogFilterValuesRequest Which filter to list values for, and the query to list them under. +// +// `query` is a full `PlatformLogsQueryRequest`, in the same shape as a record +// query - so the observer passes the query it already holds rather than +// rebuilding it. Its `startTime` and `endTime` are required, so every call here +// is scoped to a period. +// +// The other filters in `query` narrow which records the values are drawn from, +// except the one named by `filter`, whose own selections are ignored. +// +// `query.limit` and `query.sortOrder` carry no meaning here: no records are +// returned, so there is nothing to page or order. They are accepted and ignored +// rather than rejected. +type PlatformLogFilterValuesRequest struct { + // Filter The filter to list values for, named as the request field that accepts it. + Filter PlatformLogFilterValuesRequestFilter `json:"filter"` + + // MaxValues The maximum number of values to return, ordered by `count` descending then + // `value` ascending, so a truncated list holds the busiest. Named to stay + // distinct from `query.limit`, which is a record page size and is ignored + // here. + MaxValues *int `json:"maxValues,omitempty"` + + // Query A flat set of Kubernetes coordinates. Multi-value fields OR within a field; fields + // AND with each other. An absent field is not a filter. + Query PlatformLogsQueryRequest `json:"query"` + + // ValueSearch Return only values containing this text, case-insensitively. Narrows the + // *values* returned, where `query.searchPhrase` narrows the *records* they + // are drawn from. + ValueSearch *string `json:"valueSearch,omitempty"` +} + +// PlatformLogFilterValuesRequestFilter The filter to list values for, named as the request field that accepts it. +type PlatformLogFilterValuesRequestFilter string + +// PlatformLogFilterValuesResponse defines model for PlatformLogFilterValuesResponse. +type PlatformLogFilterValuesResponse struct { + // Filter The filter these values belong to, echoed from the request + Filter string `json:"filter"` + + // TookMs The time taken to compute the values in milliseconds + TookMs int `json:"tookMs"` + + // TotalValues How many distinct values match, of which at most `maxValues` were returned. + // Counting distinct values exactly is an expensive aggregation on a + // high-cardinality field, so this is a sense of scale rather than a + // guaranteed total. + TotalValues int64 `json:"totalValues"` + + // Values Distinct values, ordered by `count` descending then `value` ascending. + // Records on which the field is absent are not represented: no empty-string + // entry, because no filter value would select one. + Values []PlatformLogFilterValue `json:"values"` +} + +// PlatformLogsQueryRequest A flat set of Kubernetes coordinates. Multi-value fields OR within a field; fields +// AND with each other. An absent field is not a filter. +type PlatformLogsQueryRequest struct { + // ClusterInstance Clusters the records were collected from, as stamped by the collector + ClusterInstance *[]string `json:"clusterInstance,omitempty"` + ContainerName *[]string `json:"containerName,omitempty"` + + // EndTime Exclusive upper bound of the log window + EndTime time.Time `json:"endTime"` + + // Labels Pod labels every returned record must carry, ANDed. The observer parses the + // equality-based selector it receives and passes the resulting pairs, so the + // adapter does not implement selector syntax. Plane attribution arrives here. + Labels *map[string]string `json:"labels,omitempty"` + + // Limit The maximum number of entries to return + Limit *int `json:"limit,omitempty"` + LogLevels *[]PlatformLogsQueryRequestLogLevels `json:"logLevels,omitempty"` + + // Namespace Kubernetes namespaces of the pods + Namespace *[]string `json:"namespace,omitempty"` + PodName *[]string `json:"podName,omitempty"` + SearchPhrase *string `json:"searchPhrase,omitempty"` + + // SortOrder Sort direction on the log timestamp + SortOrder *PlatformLogsQueryRequestSortOrder `json:"sortOrder,omitempty"` + + // StartTime Inclusive lower bound of the log window + StartTime time.Time `json:"startTime"` +} + +// PlatformLogsQueryRequestLogLevels defines model for PlatformLogsQueryRequest.LogLevels. +type PlatformLogsQueryRequestLogLevels string + +// PlatformLogsQueryRequestSortOrder Sort direction on the log timestamp +type PlatformLogsQueryRequestSortOrder string + +// PlatformLogsResponse defines model for PlatformLogsResponse. +type PlatformLogsResponse struct { + // Logs The logs queried successfully + Logs []PlatformLog `json:"logs"` + + // TookMs The time taken to query the logs in milliseconds + TookMs int `json:"tookMs"` + + // Total The total number of matching log entries, capped at 1000 + Total int `json:"total"` +} + +// WorkflowLogEntry defines model for WorkflowLogEntry. +type WorkflowLogEntry struct { + // Log The log message + Log *string `json:"log,omitempty"` + + // Timestamp The timestamp of the log entry + Timestamp *time.Time `json:"timestamp,omitempty"` +} + +// WorkflowSearchScope defines model for WorkflowSearchScope. +type WorkflowSearchScope struct { + Namespace string `json:"namespace"` + + // TaskName Filter events to a specific workflow task + TaskName *string `json:"taskName,omitempty"` + WorkflowRunName *string `json:"workflowRunName,omitempty"` +} + +// HandleAlertWebhookJSONBody defines parameters for HandleAlertWebhook. +type HandleAlertWebhookJSONBody = map[string]interface{} + +// QueryEventsJSONRequestBody defines body for QueryEvents for application/json ContentType. +type QueryEventsJSONRequestBody = EventsQueryRequest + +// QueryLogsJSONRequestBody defines body for QueryLogs for application/json ContentType. +type QueryLogsJSONRequestBody = LogsQueryRequest + +// CreateAlertRuleJSONRequestBody defines body for CreateAlertRule for application/json ContentType. +type CreateAlertRuleJSONRequestBody = AlertRuleRequest + +// UpdateAlertRuleJSONRequestBody defines body for UpdateAlertRule for application/json ContentType. +type UpdateAlertRuleJSONRequestBody = AlertRuleRequest + +// HandleAlertWebhookJSONRequestBody defines body for HandleAlertWebhook for application/json ContentType. +type HandleAlertWebhookJSONRequestBody = HandleAlertWebhookJSONBody + +// QueryAuditLogFilterValuesJSONRequestBody defines body for QueryAuditLogFilterValues for application/json ContentType. +type QueryAuditLogFilterValuesJSONRequestBody = AuditLogFilterValuesRequest + +// QueryAuditLogsJSONRequestBody defines body for QueryAuditLogs for application/json ContentType. +type QueryAuditLogsJSONRequestBody = AuditLogsQueryRequest + +// QueryPlatformLogFilterValuesJSONRequestBody defines body for QueryPlatformLogFilterValues for application/json ContentType. +type QueryPlatformLogFilterValuesJSONRequestBody = PlatformLogFilterValuesRequest + +// QueryPlatformLogsJSONRequestBody defines body for QueryPlatformLogs for application/json ContentType. +type QueryPlatformLogsJSONRequestBody = PlatformLogsQueryRequest + +// AsComponentSearchScope returns the union data inside the EventsQueryRequest_SearchScope as a ComponentSearchScope +func (t EventsQueryRequest_SearchScope) AsComponentSearchScope() (ComponentSearchScope, error) { + var body ComponentSearchScope + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromComponentSearchScope overwrites any union data inside the EventsQueryRequest_SearchScope as the provided ComponentSearchScope +func (t *EventsQueryRequest_SearchScope) FromComponentSearchScope(v ComponentSearchScope) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeComponentSearchScope performs a merge with any union data inside the EventsQueryRequest_SearchScope, using the provided ComponentSearchScope +func (t *EventsQueryRequest_SearchScope) MergeComponentSearchScope(v ComponentSearchScope) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsWorkflowSearchScope returns the union data inside the EventsQueryRequest_SearchScope as a WorkflowSearchScope +func (t EventsQueryRequest_SearchScope) AsWorkflowSearchScope() (WorkflowSearchScope, error) { + var body WorkflowSearchScope + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromWorkflowSearchScope overwrites any union data inside the EventsQueryRequest_SearchScope as the provided WorkflowSearchScope +func (t *EventsQueryRequest_SearchScope) FromWorkflowSearchScope(v WorkflowSearchScope) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeWorkflowSearchScope performs a merge with any union data inside the EventsQueryRequest_SearchScope, using the provided WorkflowSearchScope +func (t *EventsQueryRequest_SearchScope) MergeWorkflowSearchScope(v WorkflowSearchScope) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t EventsQueryRequest_SearchScope) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *EventsQueryRequest_SearchScope) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsComponentSearchScope returns the union data inside the LogsQueryRequest_SearchScope as a ComponentSearchScope +func (t LogsQueryRequest_SearchScope) AsComponentSearchScope() (ComponentSearchScope, error) { + var body ComponentSearchScope + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromComponentSearchScope overwrites any union data inside the LogsQueryRequest_SearchScope as the provided ComponentSearchScope +func (t *LogsQueryRequest_SearchScope) FromComponentSearchScope(v ComponentSearchScope) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeComponentSearchScope performs a merge with any union data inside the LogsQueryRequest_SearchScope, using the provided ComponentSearchScope +func (t *LogsQueryRequest_SearchScope) MergeComponentSearchScope(v ComponentSearchScope) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsWorkflowSearchScope returns the union data inside the LogsQueryRequest_SearchScope as a WorkflowSearchScope +func (t LogsQueryRequest_SearchScope) AsWorkflowSearchScope() (WorkflowSearchScope, error) { + var body WorkflowSearchScope + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromWorkflowSearchScope overwrites any union data inside the LogsQueryRequest_SearchScope as the provided WorkflowSearchScope +func (t *LogsQueryRequest_SearchScope) FromWorkflowSearchScope(v WorkflowSearchScope) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeWorkflowSearchScope performs a merge with any union data inside the LogsQueryRequest_SearchScope, using the provided WorkflowSearchScope +func (t *LogsQueryRequest_SearchScope) MergeWorkflowSearchScope(v WorkflowSearchScope) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t LogsQueryRequest_SearchScope) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *LogsQueryRequest_SearchScope) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsLogsQueryResponseLogs0 returns the union data inside the LogsQueryResponse_Logs as a LogsQueryResponseLogs0 +func (t LogsQueryResponse_Logs) AsLogsQueryResponseLogs0() (LogsQueryResponseLogs0, error) { + var body LogsQueryResponseLogs0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromLogsQueryResponseLogs0 overwrites any union data inside the LogsQueryResponse_Logs as the provided LogsQueryResponseLogs0 +func (t *LogsQueryResponse_Logs) FromLogsQueryResponseLogs0(v LogsQueryResponseLogs0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeLogsQueryResponseLogs0 performs a merge with any union data inside the LogsQueryResponse_Logs, using the provided LogsQueryResponseLogs0 +func (t *LogsQueryResponse_Logs) MergeLogsQueryResponseLogs0(v LogsQueryResponseLogs0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsLogsQueryResponseLogs1 returns the union data inside the LogsQueryResponse_Logs as a LogsQueryResponseLogs1 +func (t LogsQueryResponse_Logs) AsLogsQueryResponseLogs1() (LogsQueryResponseLogs1, error) { + var body LogsQueryResponseLogs1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromLogsQueryResponseLogs1 overwrites any union data inside the LogsQueryResponse_Logs as the provided LogsQueryResponseLogs1 +func (t *LogsQueryResponse_Logs) FromLogsQueryResponseLogs1(v LogsQueryResponseLogs1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeLogsQueryResponseLogs1 performs a merge with any union data inside the LogsQueryResponse_Logs, using the provided LogsQueryResponseLogs1 +func (t *LogsQueryResponse_Logs) MergeLogsQueryResponseLogs1(v LogsQueryResponseLogs1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t LogsQueryResponse_Logs) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *LogsQueryResponse_Logs) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/observability-logs-dynatrace/internal/api/gen/server.gen.go b/observability-logs-dynatrace/internal/api/gen/server.gen.go new file mode 100644 index 00000000..d4cc95df --- /dev/null +++ b/observability-logs-dynatrace/internal/api/gen/server.gen.go @@ -0,0 +1,1721 @@ +//go:build go1.22 + +// Package gen provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package gen + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/oapi-codegen/runtime" + strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Query events + // (POST /api/v1/events/query) + QueryEvents(w http.ResponseWriter, r *http.Request) + // Query logs + // (POST /api/v1/logs/query) + QueryLogs(w http.ResponseWriter, r *http.Request) + // Create alert rule + // (POST /api/v1alpha1/alerts/rules) + CreateAlertRule(w http.ResponseWriter, r *http.Request) + // Delete alert rule + // (DELETE /api/v1alpha1/alerts/rules/{ruleName}) + DeleteAlertRule(w http.ResponseWriter, r *http.Request, ruleName string) + // Get alert rule + // (GET /api/v1alpha1/alerts/rules/{ruleName}) + GetAlertRule(w http.ResponseWriter, r *http.Request, ruleName string) + // Update alert rule + // (PUT /api/v1alpha1/alerts/rules/{ruleName}) + UpdateAlertRule(w http.ResponseWriter, r *http.Request, ruleName string) + // Handles triggered alerts from the alerting backend + // (POST /api/v1alpha1/alerts/webhook) + HandleAlertWebhook(w http.ResponseWriter, r *http.Request) + // List the distinct values one audit log filter can take + // (POST /api/v1alpha1/audit-logs/filter-values) + QueryAuditLogFilterValues(w http.ResponseWriter, r *http.Request) + // Query audit logs + // (POST /api/v1alpha1/audit-logs/query) + QueryAuditLogs(w http.ResponseWriter, r *http.Request) + // List the distinct values one platform logs filter can take + // (POST /api/v1alpha1/platform-logs/filter-values) + QueryPlatformLogFilterValues(w http.ResponseWriter, r *http.Request) + // Query platform logs + // (POST /api/v1alpha1/platform-logs/query) + QueryPlatformLogs(w http.ResponseWriter, r *http.Request) + // Health check + // (GET /health) + Health(w http.ResponseWriter, r *http.Request) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// QueryEvents operation middleware +func (siw *ServerInterfaceWrapper) QueryEvents(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.QueryEvents(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// QueryLogs operation middleware +func (siw *ServerInterfaceWrapper) QueryLogs(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.QueryLogs(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateAlertRule operation middleware +func (siw *ServerInterfaceWrapper) CreateAlertRule(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateAlertRule(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteAlertRule operation middleware +func (siw *ServerInterfaceWrapper) DeleteAlertRule(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "ruleName" ------------- + var ruleName string + + err = runtime.BindStyledParameterWithOptions("simple", "ruleName", r.PathValue("ruleName"), &ruleName, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ruleName", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteAlertRule(w, r, ruleName) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetAlertRule operation middleware +func (siw *ServerInterfaceWrapper) GetAlertRule(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "ruleName" ------------- + var ruleName string + + err = runtime.BindStyledParameterWithOptions("simple", "ruleName", r.PathValue("ruleName"), &ruleName, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ruleName", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetAlertRule(w, r, ruleName) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateAlertRule operation middleware +func (siw *ServerInterfaceWrapper) UpdateAlertRule(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "ruleName" ------------- + var ruleName string + + err = runtime.BindStyledParameterWithOptions("simple", "ruleName", r.PathValue("ruleName"), &ruleName, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ruleName", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateAlertRule(w, r, ruleName) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// HandleAlertWebhook operation middleware +func (siw *ServerInterfaceWrapper) HandleAlertWebhook(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.HandleAlertWebhook(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// QueryAuditLogFilterValues operation middleware +func (siw *ServerInterfaceWrapper) QueryAuditLogFilterValues(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.QueryAuditLogFilterValues(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// QueryAuditLogs operation middleware +func (siw *ServerInterfaceWrapper) QueryAuditLogs(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.QueryAuditLogs(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// QueryPlatformLogFilterValues operation middleware +func (siw *ServerInterfaceWrapper) QueryPlatformLogFilterValues(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.QueryPlatformLogFilterValues(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// QueryPlatformLogs operation middleware +func (siw *ServerInterfaceWrapper) QueryPlatformLogs(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.QueryPlatformLogs(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// Health operation middleware +func (siw *ServerInterfaceWrapper) Health(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Health(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of http.ServeMux. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + ServeHTTP(w http.ResponseWriter, r *http.Request) +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc("POST "+options.BaseURL+"/api/v1/events/query", wrapper.QueryEvents) + m.HandleFunc("POST "+options.BaseURL+"/api/v1/logs/query", wrapper.QueryLogs) + m.HandleFunc("POST "+options.BaseURL+"/api/v1alpha1/alerts/rules", wrapper.CreateAlertRule) + m.HandleFunc("DELETE "+options.BaseURL+"/api/v1alpha1/alerts/rules/{ruleName}", wrapper.DeleteAlertRule) + m.HandleFunc("GET "+options.BaseURL+"/api/v1alpha1/alerts/rules/{ruleName}", wrapper.GetAlertRule) + m.HandleFunc("PUT "+options.BaseURL+"/api/v1alpha1/alerts/rules/{ruleName}", wrapper.UpdateAlertRule) + m.HandleFunc("POST "+options.BaseURL+"/api/v1alpha1/alerts/webhook", wrapper.HandleAlertWebhook) + m.HandleFunc("POST "+options.BaseURL+"/api/v1alpha1/audit-logs/filter-values", wrapper.QueryAuditLogFilterValues) + m.HandleFunc("POST "+options.BaseURL+"/api/v1alpha1/audit-logs/query", wrapper.QueryAuditLogs) + m.HandleFunc("POST "+options.BaseURL+"/api/v1alpha1/platform-logs/filter-values", wrapper.QueryPlatformLogFilterValues) + m.HandleFunc("POST "+options.BaseURL+"/api/v1alpha1/platform-logs/query", wrapper.QueryPlatformLogs) + m.HandleFunc("GET "+options.BaseURL+"/health", wrapper.Health) + + return m +} + +type QueryEventsRequestObject struct { + Body *QueryEventsJSONRequestBody +} + +type QueryEventsResponseObject interface { + VisitQueryEventsResponse(w http.ResponseWriter) error +} + +type QueryEvents200JSONResponse EventsQueryResponse + +func (response QueryEvents200JSONResponse) VisitQueryEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type QueryEvents400JSONResponse ErrorResponse + +func (response QueryEvents400JSONResponse) VisitQueryEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type QueryEvents401JSONResponse ErrorResponse + +func (response QueryEvents401JSONResponse) VisitQueryEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type QueryEvents403JSONResponse ErrorResponse + +func (response QueryEvents403JSONResponse) VisitQueryEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type QueryEvents500JSONResponse ErrorResponse + +func (response QueryEvents500JSONResponse) VisitQueryEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type QueryEvents501JSONResponse ErrorResponse + +func (response QueryEvents501JSONResponse) VisitQueryEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + +type QueryLogsRequestObject struct { + Body *QueryLogsJSONRequestBody +} + +type QueryLogsResponseObject interface { + VisitQueryLogsResponse(w http.ResponseWriter) error +} + +type QueryLogs200JSONResponse LogsQueryResponse + +func (response QueryLogs200JSONResponse) VisitQueryLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type QueryLogs400JSONResponse ErrorResponse + +func (response QueryLogs400JSONResponse) VisitQueryLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type QueryLogs401JSONResponse ErrorResponse + +func (response QueryLogs401JSONResponse) VisitQueryLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type QueryLogs403JSONResponse ErrorResponse + +func (response QueryLogs403JSONResponse) VisitQueryLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type QueryLogs500JSONResponse ErrorResponse + +func (response QueryLogs500JSONResponse) VisitQueryLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type CreateAlertRuleRequestObject struct { + Body *CreateAlertRuleJSONRequestBody +} + +type CreateAlertRuleResponseObject interface { + VisitCreateAlertRuleResponse(w http.ResponseWriter) error +} + +type CreateAlertRule201JSONResponse AlertingRuleSyncResponse + +func (response CreateAlertRule201JSONResponse) VisitCreateAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type CreateAlertRule400JSONResponse ErrorResponse + +func (response CreateAlertRule400JSONResponse) VisitCreateAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type CreateAlertRule409JSONResponse ErrorResponse + +func (response CreateAlertRule409JSONResponse) VisitCreateAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type CreateAlertRule500JSONResponse ErrorResponse + +func (response CreateAlertRule500JSONResponse) VisitCreateAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteAlertRuleRequestObject struct { + RuleName string `json:"ruleName"` +} + +type DeleteAlertRuleResponseObject interface { + VisitDeleteAlertRuleResponse(w http.ResponseWriter) error +} + +type DeleteAlertRule200JSONResponse AlertingRuleSyncResponse + +func (response DeleteAlertRule200JSONResponse) VisitDeleteAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteAlertRule400JSONResponse ErrorResponse + +func (response DeleteAlertRule400JSONResponse) VisitDeleteAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteAlertRule404JSONResponse ErrorResponse + +func (response DeleteAlertRule404JSONResponse) VisitDeleteAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteAlertRule500JSONResponse ErrorResponse + +func (response DeleteAlertRule500JSONResponse) VisitDeleteAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlertRuleRequestObject struct { + RuleName string `json:"ruleName"` +} + +type GetAlertRuleResponseObject interface { + VisitGetAlertRuleResponse(w http.ResponseWriter) error +} + +type GetAlertRule200JSONResponse AlertRuleResponse + +func (response GetAlertRule200JSONResponse) VisitGetAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlertRule400JSONResponse ErrorResponse + +func (response GetAlertRule400JSONResponse) VisitGetAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlertRule404JSONResponse ErrorResponse + +func (response GetAlertRule404JSONResponse) VisitGetAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlertRule500JSONResponse ErrorResponse + +func (response GetAlertRule500JSONResponse) VisitGetAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateAlertRuleRequestObject struct { + RuleName string `json:"ruleName"` + Body *UpdateAlertRuleJSONRequestBody +} + +type UpdateAlertRuleResponseObject interface { + VisitUpdateAlertRuleResponse(w http.ResponseWriter) error +} + +type UpdateAlertRule200JSONResponse AlertingRuleSyncResponse + +func (response UpdateAlertRule200JSONResponse) VisitUpdateAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateAlertRule400JSONResponse ErrorResponse + +func (response UpdateAlertRule400JSONResponse) VisitUpdateAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateAlertRule404JSONResponse ErrorResponse + +func (response UpdateAlertRule404JSONResponse) VisitUpdateAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateAlertRule500JSONResponse ErrorResponse + +func (response UpdateAlertRule500JSONResponse) VisitUpdateAlertRuleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type HandleAlertWebhookRequestObject struct { + Body *HandleAlertWebhookJSONRequestBody +} + +type HandleAlertWebhookResponseObject interface { + VisitHandleAlertWebhookResponse(w http.ResponseWriter) error +} + +type HandleAlertWebhook200JSONResponse AlertWebhookResponse + +func (response HandleAlertWebhook200JSONResponse) VisitHandleAlertWebhookResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type HandleAlertWebhook400JSONResponse ErrorResponse + +func (response HandleAlertWebhook400JSONResponse) VisitHandleAlertWebhookResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type HandleAlertWebhook500JSONResponse ErrorResponse + +func (response HandleAlertWebhook500JSONResponse) VisitHandleAlertWebhookResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogFilterValuesRequestObject struct { + Body *QueryAuditLogFilterValuesJSONRequestBody +} + +type QueryAuditLogFilterValuesResponseObject interface { + VisitQueryAuditLogFilterValuesResponse(w http.ResponseWriter) error +} + +type QueryAuditLogFilterValues200JSONResponse AuditLogFilterValuesResponse + +func (response QueryAuditLogFilterValues200JSONResponse) VisitQueryAuditLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogFilterValues400JSONResponse ErrorResponse + +func (response QueryAuditLogFilterValues400JSONResponse) VisitQueryAuditLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogFilterValues401JSONResponse ErrorResponse + +func (response QueryAuditLogFilterValues401JSONResponse) VisitQueryAuditLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogFilterValues403JSONResponse ErrorResponse + +func (response QueryAuditLogFilterValues403JSONResponse) VisitQueryAuditLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogFilterValues500JSONResponse ErrorResponse + +func (response QueryAuditLogFilterValues500JSONResponse) VisitQueryAuditLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogFilterValues501JSONResponse ErrorResponse + +func (response QueryAuditLogFilterValues501JSONResponse) VisitQueryAuditLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogsRequestObject struct { + Body *QueryAuditLogsJSONRequestBody +} + +type QueryAuditLogsResponseObject interface { + VisitQueryAuditLogsResponse(w http.ResponseWriter) error +} + +type QueryAuditLogs200JSONResponse AuditLogsResponse + +func (response QueryAuditLogs200JSONResponse) VisitQueryAuditLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogs400JSONResponse ErrorResponse + +func (response QueryAuditLogs400JSONResponse) VisitQueryAuditLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogs401JSONResponse ErrorResponse + +func (response QueryAuditLogs401JSONResponse) VisitQueryAuditLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogs403JSONResponse ErrorResponse + +func (response QueryAuditLogs403JSONResponse) VisitQueryAuditLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogs500JSONResponse ErrorResponse + +func (response QueryAuditLogs500JSONResponse) VisitQueryAuditLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type QueryAuditLogs501JSONResponse ErrorResponse + +func (response QueryAuditLogs501JSONResponse) VisitQueryAuditLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogFilterValuesRequestObject struct { + Body *QueryPlatformLogFilterValuesJSONRequestBody +} + +type QueryPlatformLogFilterValuesResponseObject interface { + VisitQueryPlatformLogFilterValuesResponse(w http.ResponseWriter) error +} + +type QueryPlatformLogFilterValues200JSONResponse PlatformLogFilterValuesResponse + +func (response QueryPlatformLogFilterValues200JSONResponse) VisitQueryPlatformLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogFilterValues400JSONResponse ErrorResponse + +func (response QueryPlatformLogFilterValues400JSONResponse) VisitQueryPlatformLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogFilterValues401JSONResponse ErrorResponse + +func (response QueryPlatformLogFilterValues401JSONResponse) VisitQueryPlatformLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogFilterValues403JSONResponse ErrorResponse + +func (response QueryPlatformLogFilterValues403JSONResponse) VisitQueryPlatformLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogFilterValues500JSONResponse ErrorResponse + +func (response QueryPlatformLogFilterValues500JSONResponse) VisitQueryPlatformLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogFilterValues501JSONResponse ErrorResponse + +func (response QueryPlatformLogFilterValues501JSONResponse) VisitQueryPlatformLogFilterValuesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogsRequestObject struct { + Body *QueryPlatformLogsJSONRequestBody +} + +type QueryPlatformLogsResponseObject interface { + VisitQueryPlatformLogsResponse(w http.ResponseWriter) error +} + +type QueryPlatformLogs200JSONResponse PlatformLogsResponse + +func (response QueryPlatformLogs200JSONResponse) VisitQueryPlatformLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogs400JSONResponse ErrorResponse + +func (response QueryPlatformLogs400JSONResponse) VisitQueryPlatformLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogs401JSONResponse ErrorResponse + +func (response QueryPlatformLogs401JSONResponse) VisitQueryPlatformLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogs403JSONResponse ErrorResponse + +func (response QueryPlatformLogs403JSONResponse) VisitQueryPlatformLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogs500JSONResponse ErrorResponse + +func (response QueryPlatformLogs500JSONResponse) VisitQueryPlatformLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type QueryPlatformLogs501JSONResponse ErrorResponse + +func (response QueryPlatformLogs501JSONResponse) VisitQueryPlatformLogsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(501) + + return json.NewEncoder(w).Encode(response) +} + +type HealthRequestObject struct { +} + +type HealthResponseObject interface { + VisitHealthResponse(w http.ResponseWriter) error +} + +type Health200JSONResponse struct { + Status *string `json:"status,omitempty"` +} + +func (response Health200JSONResponse) VisitHealthResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type Health503JSONResponse struct { + Error *string `json:"error,omitempty"` + Status *string `json:"status,omitempty"` +} + +func (response Health503JSONResponse) VisitHealthResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(503) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Query events + // (POST /api/v1/events/query) + QueryEvents(ctx context.Context, request QueryEventsRequestObject) (QueryEventsResponseObject, error) + // Query logs + // (POST /api/v1/logs/query) + QueryLogs(ctx context.Context, request QueryLogsRequestObject) (QueryLogsResponseObject, error) + // Create alert rule + // (POST /api/v1alpha1/alerts/rules) + CreateAlertRule(ctx context.Context, request CreateAlertRuleRequestObject) (CreateAlertRuleResponseObject, error) + // Delete alert rule + // (DELETE /api/v1alpha1/alerts/rules/{ruleName}) + DeleteAlertRule(ctx context.Context, request DeleteAlertRuleRequestObject) (DeleteAlertRuleResponseObject, error) + // Get alert rule + // (GET /api/v1alpha1/alerts/rules/{ruleName}) + GetAlertRule(ctx context.Context, request GetAlertRuleRequestObject) (GetAlertRuleResponseObject, error) + // Update alert rule + // (PUT /api/v1alpha1/alerts/rules/{ruleName}) + UpdateAlertRule(ctx context.Context, request UpdateAlertRuleRequestObject) (UpdateAlertRuleResponseObject, error) + // Handles triggered alerts from the alerting backend + // (POST /api/v1alpha1/alerts/webhook) + HandleAlertWebhook(ctx context.Context, request HandleAlertWebhookRequestObject) (HandleAlertWebhookResponseObject, error) + // List the distinct values one audit log filter can take + // (POST /api/v1alpha1/audit-logs/filter-values) + QueryAuditLogFilterValues(ctx context.Context, request QueryAuditLogFilterValuesRequestObject) (QueryAuditLogFilterValuesResponseObject, error) + // Query audit logs + // (POST /api/v1alpha1/audit-logs/query) + QueryAuditLogs(ctx context.Context, request QueryAuditLogsRequestObject) (QueryAuditLogsResponseObject, error) + // List the distinct values one platform logs filter can take + // (POST /api/v1alpha1/platform-logs/filter-values) + QueryPlatformLogFilterValues(ctx context.Context, request QueryPlatformLogFilterValuesRequestObject) (QueryPlatformLogFilterValuesResponseObject, error) + // Query platform logs + // (POST /api/v1alpha1/platform-logs/query) + QueryPlatformLogs(ctx context.Context, request QueryPlatformLogsRequestObject) (QueryPlatformLogsResponseObject, error) + // Health check + // (GET /health) + Health(ctx context.Context, request HealthRequestObject) (HealthResponseObject, error) +} + +type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc +type StrictMiddlewareFunc = strictnethttp.StrictHTTPMiddlewareFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// QueryEvents operation middleware +func (sh *strictHandler) QueryEvents(w http.ResponseWriter, r *http.Request) { + var request QueryEventsRequestObject + + var body QueryEventsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.QueryEvents(ctx, request.(QueryEventsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "QueryEvents") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(QueryEventsResponseObject); ok { + if err := validResponse.VisitQueryEventsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// QueryLogs operation middleware +func (sh *strictHandler) QueryLogs(w http.ResponseWriter, r *http.Request) { + var request QueryLogsRequestObject + + var body QueryLogsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.QueryLogs(ctx, request.(QueryLogsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "QueryLogs") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(QueryLogsResponseObject); ok { + if err := validResponse.VisitQueryLogsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// CreateAlertRule operation middleware +func (sh *strictHandler) CreateAlertRule(w http.ResponseWriter, r *http.Request) { + var request CreateAlertRuleRequestObject + + var body CreateAlertRuleJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreateAlertRule(ctx, request.(CreateAlertRuleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateAlertRule") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreateAlertRuleResponseObject); ok { + if err := validResponse.VisitCreateAlertRuleResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteAlertRule operation middleware +func (sh *strictHandler) DeleteAlertRule(w http.ResponseWriter, r *http.Request, ruleName string) { + var request DeleteAlertRuleRequestObject + + request.RuleName = ruleName + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DeleteAlertRule(ctx, request.(DeleteAlertRuleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteAlertRule") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DeleteAlertRuleResponseObject); ok { + if err := validResponse.VisitDeleteAlertRuleResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetAlertRule operation middleware +func (sh *strictHandler) GetAlertRule(w http.ResponseWriter, r *http.Request, ruleName string) { + var request GetAlertRuleRequestObject + + request.RuleName = ruleName + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetAlertRule(ctx, request.(GetAlertRuleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetAlertRule") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetAlertRuleResponseObject); ok { + if err := validResponse.VisitGetAlertRuleResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateAlertRule operation middleware +func (sh *strictHandler) UpdateAlertRule(w http.ResponseWriter, r *http.Request, ruleName string) { + var request UpdateAlertRuleRequestObject + + request.RuleName = ruleName + + var body UpdateAlertRuleJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateAlertRule(ctx, request.(UpdateAlertRuleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateAlertRule") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateAlertRuleResponseObject); ok { + if err := validResponse.VisitUpdateAlertRuleResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// HandleAlertWebhook operation middleware +func (sh *strictHandler) HandleAlertWebhook(w http.ResponseWriter, r *http.Request) { + var request HandleAlertWebhookRequestObject + + var body HandleAlertWebhookJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.HandleAlertWebhook(ctx, request.(HandleAlertWebhookRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "HandleAlertWebhook") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(HandleAlertWebhookResponseObject); ok { + if err := validResponse.VisitHandleAlertWebhookResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// QueryAuditLogFilterValues operation middleware +func (sh *strictHandler) QueryAuditLogFilterValues(w http.ResponseWriter, r *http.Request) { + var request QueryAuditLogFilterValuesRequestObject + + var body QueryAuditLogFilterValuesJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.QueryAuditLogFilterValues(ctx, request.(QueryAuditLogFilterValuesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "QueryAuditLogFilterValues") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(QueryAuditLogFilterValuesResponseObject); ok { + if err := validResponse.VisitQueryAuditLogFilterValuesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// QueryAuditLogs operation middleware +func (sh *strictHandler) QueryAuditLogs(w http.ResponseWriter, r *http.Request) { + var request QueryAuditLogsRequestObject + + var body QueryAuditLogsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.QueryAuditLogs(ctx, request.(QueryAuditLogsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "QueryAuditLogs") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(QueryAuditLogsResponseObject); ok { + if err := validResponse.VisitQueryAuditLogsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// QueryPlatformLogFilterValues operation middleware +func (sh *strictHandler) QueryPlatformLogFilterValues(w http.ResponseWriter, r *http.Request) { + var request QueryPlatformLogFilterValuesRequestObject + + var body QueryPlatformLogFilterValuesJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.QueryPlatformLogFilterValues(ctx, request.(QueryPlatformLogFilterValuesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "QueryPlatformLogFilterValues") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(QueryPlatformLogFilterValuesResponseObject); ok { + if err := validResponse.VisitQueryPlatformLogFilterValuesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// QueryPlatformLogs operation middleware +func (sh *strictHandler) QueryPlatformLogs(w http.ResponseWriter, r *http.Request) { + var request QueryPlatformLogsRequestObject + + var body QueryPlatformLogsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.QueryPlatformLogs(ctx, request.(QueryPlatformLogsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "QueryPlatformLogs") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(QueryPlatformLogsResponseObject); ok { + if err := validResponse.VisitQueryPlatformLogsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Health operation middleware +func (sh *strictHandler) Health(w http.ResponseWriter, r *http.Request) { + var request HealthRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Health(ctx, request.(HealthRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Health") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(HealthResponseObject); ok { + if err := validResponse.VisitHealthResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "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", +} + +// GetSwagger returns the content of the embedded swagger specification file +// or error if failed to decode +func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cached of a decoded swagger spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. The external references of Swagger specification are resolved. +// The logic of resolving external references is tightly connected to "import-mapping" feature. +// Externally referenced files must be embedded in the corresponding golang packages. +// Urls can be supported but this task was out of the scope. +func GetSwagger() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} diff --git a/observability-logs-dynatrace/internal/audit_handlers.go b/observability-logs-dynatrace/internal/audit_handlers.go new file mode 100644 index 00000000..1f9ffe60 --- /dev/null +++ b/observability-logs-dynatrace/internal/audit_handlers.go @@ -0,0 +1,395 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "log/slog" + + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/api/gen" + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/dynatrace" +) + +// Contract limits, clamped here because the generated server does not enforce them. +const ( + maxAuditLimit = 1000 + defaultAuditFilterValues = 100 + maxAuditFilterValues = 1000 +) + +// QueryAuditLogs implements POST /api/v1alpha1/audit-logs/query. +func (h *LogsHandler) QueryAuditLogs( + ctx context.Context, request gen.QueryAuditLogsRequestObject, +) (gen.QueryAuditLogsResponseObject, error) { + if request.Body == nil { + return gen.QueryAuditLogs400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("request body is required"), + }, nil + } + body := request.Body + + if !body.EndTime.After(body.StartTime) { + return gen.QueryAuditLogs400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("endTime must be after startTime"), + }, nil + } + + if msg := invalidAuditEnum(body); msg != "" { + return gen.QueryAuditLogs400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr(msg), + }, nil + } + + timelineInterval := "" + if body.IncludeTimeline != nil && *body.IncludeTimeline { + requested := "" + if body.TimelineInterval != nil { + requested = *body.TimelineInterval + } + resolved, err := dynatrace.ResolveTimelineInterval(requested, body.StartTime, body.EndTime) + if err != nil { + return gen.QueryAuditLogs400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr(err.Error()), + }, nil + } + timelineInterval = resolved + } + + result, err := h.backend.GetAuditLogs(ctx, toAuditLogsParams(body), timelineInterval) + if err != nil { + h.logger.Error("Failed to query audit logs", + slog.String("function", "QueryAuditLogs"), + slog.Any("error", err), + ) + return gen.QueryAuditLogs500JSONResponse{ + Title: ptr(gen.InternalServerError), + Message: ptr("internal server error"), + }, nil + } + + records := make([]gen.AuditLogRecord, 0, len(result.Records)) + for _, r := range result.Records { + records = append(records, toGenAuditLogRecord(r)) + } + + response := gen.AuditLogsResponse{ + Records: records, + Total: result.Total, + TookMs: int64(result.Took), + } + if result.Timeline != nil { + response.Timeline = toGenAuditTimeline(result.Timeline) + } + + return gen.QueryAuditLogs200JSONResponse(response), nil +} + +// QueryAuditLogFilterValues implements POST /api/v1alpha1/audit-logs/filter-values. +func (h *LogsHandler) QueryAuditLogFilterValues( + ctx context.Context, request gen.QueryAuditLogFilterValuesRequestObject, +) (gen.QueryAuditLogFilterValuesResponseObject, error) { + if request.Body == nil { + return gen.QueryAuditLogFilterValues400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("request body is required"), + }, nil + } + body := request.Body + + filter := string(body.Filter) + if !dynatrace.IsAuditFilter(filter) { + return gen.QueryAuditLogFilterValues400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("unknown filter: " + filter), + }, nil + } + + if !body.Query.EndTime.After(body.Query.StartTime) { + return gen.QueryAuditLogFilterValues400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("query.endTime must be after query.startTime"), + }, nil + } + + if msg := invalidAuditEnum(&body.Query); msg != "" { + return gen.QueryAuditLogFilterValues400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr(msg), + }, nil + } + + params := toAuditLogsParams(&body.Query) + clearAuditFilter(¶ms, body.Filter) + + valueSearch := "" + if body.ValueSearch != nil { + valueSearch = *body.ValueSearch + } + maxValues := defaultAuditFilterValues + if body.MaxValues != nil && *body.MaxValues > 0 { + maxValues = min(*body.MaxValues, maxAuditFilterValues) + } + + result, err := h.backend.GetAuditLogFilterValues(ctx, params, filter, valueSearch, maxValues) + if err != nil { + h.logger.Error("Failed to query audit log filter values", + slog.String("function", "QueryAuditLogFilterValues"), + slog.String("filter", filter), + slog.Any("error", err), + ) + return gen.QueryAuditLogFilterValues500JSONResponse{ + Title: ptr(gen.InternalServerError), + Message: ptr("internal server error"), + }, nil + } + + values := make([]gen.AuditLogFilterValue, 0, len(result.Values)) + for _, v := range result.Values { + values = append(values, gen.AuditLogFilterValue{Value: v.Value, Count: v.Count}) + } + + return gen.QueryAuditLogFilterValues200JSONResponse{ + Filter: filter, + Values: values, + TotalValues: result.TotalValues, + TookMs: int64(result.Took), + }, nil +} + +// invalidAuditEnum reports the first closed-set field holding a value outside its +// declared enum, or "" when every supplied value is known. category, result, surface +// and sortOrder are documented in the OpenAPI contract as rejecting an unknown value +// with 400 rather than silently matching nothing. +func invalidAuditEnum(body *gen.AuditLogsQueryRequest) string { + if body.Category != nil { + for _, c := range *body.Category { + switch c { + case gen.Access, gen.Authorization, gen.Management: + default: + return "unknown category: " + string(c) + } + } + } + if body.Result != nil { + for _, r := range *body.Result { + switch r { + case gen.AuditLogsQueryRequestResultDenied, gen.AuditLogsQueryRequestResultFailure, + gen.AuditLogsQueryRequestResultSuccess: + default: + return "unknown result: " + string(r) + } + } + } + if body.Surface != nil { + for _, s := range *body.Surface { + switch s { + case gen.Mcp, gen.Rest: + default: + return "unknown surface: " + string(s) + } + } + } + if body.SortOrder != nil { + switch *body.SortOrder { + case gen.AuditLogsQueryRequestSortOrderAsc, gen.AuditLogsQueryRequestSortOrderDesc: + default: + return "unknown sortOrder: " + string(*body.SortOrder) + } + } + return "" +} + +func toAuditLogsParams(body *gen.AuditLogsQueryRequest) dynatrace.AuditLogsParams { + params := dynatrace.AuditLogsParams{ + StartTime: body.StartTime, + EndTime: body.EndTime, + Actions: derefSlice(body.Action), + Producers: derefSlice(body.Producer), + OperationIDs: derefSlice(body.OperationId), + RequestIDs: derefSlice(body.RequestId), + EventIDs: derefSlice(body.EventId), + SourceIPs: derefSlice(body.SourceIp), + UserAgents: derefSlice(body.UserAgent), + Limit: 100, + SortOrder: "desc", + } + + if body.Actor != nil { + params.ActorIDs = derefSlice(body.Actor.Id) + params.ActorTypes = derefSlice(body.Actor.Type) + params.ActorIssuers = derefSlice(body.Actor.Issuer) + params.ActorSessionIDs = derefSlice(body.Actor.SessionId) + params.ActorEntitlements = derefSlice(body.Actor.Entitlements) + } + + if body.Resource != nil { + params.ResourceTypes = derefSlice(body.Resource.Type) + params.ResourceNamespaces = derefSlice(body.Resource.Namespace) + params.ResourceEnvironments = derefSlice(body.Resource.Environment) + params.ResourceProjects = derefSlice(body.Resource.Project) + params.ResourceComponents = derefSlice(body.Resource.Component) + params.ResourceResources = derefSlice(body.Resource.Resource) + params.ResourceNames = derefSlice(body.Resource.Name) + } + + if body.Category != nil { + for _, c := range *body.Category { + params.Categories = append(params.Categories, string(c)) + } + } + if body.Result != nil { + for _, r := range *body.Result { + params.Results = append(params.Results, string(r)) + } + } + if body.Surface != nil { + for _, s := range *body.Surface { + params.Surfaces = append(params.Surfaces, string(s)) + } + } + + if body.SearchPhrase != nil { + params.SearchPhrase = *body.SearchPhrase + } + if body.Limit != nil && *body.Limit > 0 { + params.Limit = min(*body.Limit, maxAuditLimit) + } + if body.SortOrder != nil { + params.SortOrder = string(*body.SortOrder) + } + + return params +} + +// clearAuditFilter drops a filter's own selections so its picker still offers alternatives. +func clearAuditFilter(params *dynatrace.AuditLogsParams, filter gen.AuditLogFilterValuesRequestFilter) { + switch filter { + case "actor.id": + params.ActorIDs = nil + case "actor.type": + params.ActorTypes = nil + case "actor.issuer": + params.ActorIssuers = nil + case "actor.session_id": + params.ActorSessionIDs = nil + case "actor.entitlements": + params.ActorEntitlements = nil + case "resource.type": + params.ResourceTypes = nil + case "resource.namespace": + params.ResourceNamespaces = nil + case "resource.environment": + params.ResourceEnvironments = nil + case "resource.project": + params.ResourceProjects = nil + case "resource.component": + params.ResourceComponents = nil + case "resource.resource": + params.ResourceResources = nil + case "resource.name": + params.ResourceNames = nil + case "action": + params.Actions = nil + case "category": + params.Categories = nil + case "result": + params.Results = nil + case "producer": + params.Producers = nil + case "surface": + params.Surfaces = nil + case "operation_id": + params.OperationIDs = nil + case "source_ip": + params.SourceIPs = nil + case "user_agent": + params.UserAgents = nil + } +} + +func toGenAuditLogRecord(r dynatrace.AuditRecord) gen.AuditLogRecord { + record := gen.AuditLogRecord{ + SchemaVersion: r.SchemaVersion, + EventId: r.EventID, + EventTime: r.EventTime, + Actor: gen.AuditLogActor{ + Type: r.Actor.Type, + Id: r.Actor.ID, + Issuer: strPtr(r.Actor.Issuer), + SessionId: strPtr(r.Actor.SessionID), + }, + Action: r.Action, + Category: r.Category, + Result: r.Result, + RequestId: strPtr(r.RequestID), + SourceIp: strPtr(r.SourceIP), + UserAgent: strPtr(r.UserAgent), + Producer: strPtr(r.Producer), + Surface: strPtr(r.Surface), + OperationId: strPtr(r.OperationID), + } + + if len(r.Actor.Entitlements) > 0 { + entitlements := r.Actor.Entitlements + record.Actor.Entitlements = &entitlements + } + if len(r.Metadata) > 0 { + metadata := r.Metadata + record.Metadata = &metadata + } + if r.HTTP != nil { + record.Http = &gen.AuditLogHTTPInfo{ + Method: strPtr(r.HTTP.Method), + Path: strPtr(r.HTTP.Path), + } + } + if r.Resource != nil { + resource := gen.AuditLogResource{ + Type: strPtr(r.Resource.Type), + Namespace: strPtr(r.Resource.Namespace), + Environment: strPtr(r.Resource.Environment), + Project: strPtr(r.Resource.Project), + Component: strPtr(r.Resource.Component), + Resource: strPtr(r.Resource.Resource), + Uid: strPtr(r.Resource.UID), + Name: strPtr(r.Resource.Name), + } + if len(r.Resource.Metadata) > 0 { + metadata := r.Resource.Metadata + resource.Metadata = &metadata + } + record.Resource = &resource + } + + collector := gen.AuditLogCollectorInfo{ + NamespaceName: strPtr(r.Collector.NamespaceName), + PodName: strPtr(r.Collector.PodName), + ContainerName: strPtr(r.Collector.ContainerName), + } + if collector != (gen.AuditLogCollectorInfo{}) { + record.Collector = &collector + } + + return record +} + +func toGenAuditTimeline(t *dynatrace.AuditTimeline) *gen.AuditLogTimeline { + buckets := make([]gen.AuditLogTimelineBucket, 0, len(t.Buckets)) + for _, b := range t.Buckets { + bucket := gen.AuditLogTimelineBucket{ + StartTime: b.StartTime, + Total: b.Total, + } + if len(b.Counts) > 0 { + counts := b.Counts + bucket.Counts = &counts + } + buckets = append(buckets, bucket) + } + return &gen.AuditLogTimeline{Interval: t.Interval, Buckets: buckets} +} diff --git a/observability-logs-dynatrace/internal/config.go b/observability-logs-dynatrace/internal/config.go new file mode 100644 index 00000000..5a58d720 --- /dev/null +++ b/observability-logs-dynatrace/internal/config.go @@ -0,0 +1,117 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "fmt" + "log/slog" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/dynatrace" +) + +// Auth modes for the Grail query API. +const ( + AuthModePlatformToken = "platformToken" + AuthModeOAuth = "oauth" +) + +// Config is the adapter configuration, read from the environment. +type Config struct { + ServerPort string + LogLevel slog.Level + + PlatformURL string + AuthMode string + QueryTimeout time.Duration + + PlatformToken string + OAuth dynatrace.OAuthConfig + + ContainerLogsSource string + AuditLogsSource string + AuditBucket string +} + +// LoadConfig loads configuration from environment variables. +func LoadConfig() (*Config, error) { + cfg := &Config{ + ServerPort: getEnv("SERVER_PORT", "9098"), + LogLevel: parseLogLevel(os.Getenv("LOG_LEVEL")), + PlatformURL: strings.TrimRight(getEnv("DT_PLATFORM_URL", ""), "/"), + AuthMode: getEnv("DT_AUTH_MODE", AuthModePlatformToken), + PlatformToken: os.Getenv("DT_PLATFORM_TOKEN"), + ContainerLogsSource: getEnv("DT_CONTAINER_LOGS_SOURCE", "openchoreo-container-logs"), + AuditLogsSource: getEnv("DT_AUDIT_LOGS_SOURCE", "openchoreo-audit-logs"), + AuditBucket: os.Getenv("DT_AUDIT_BUCKET"), + OAuth: dynatrace.OAuthConfig{ + TokenURL: getEnv("DT_OAUTH_TOKEN_URL", dynatrace.DefaultOAuthTokenURL), + ClientID: os.Getenv("DT_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("DT_OAUTH_CLIENT_SECRET"), + Scope: getEnv("DT_OAUTH_SCOPE", dynatrace.DefaultOAuthScope), + Resource: os.Getenv("DT_OAUTH_RESOURCE"), + }, + } + + if _, err := strconv.Atoi(cfg.ServerPort); err != nil { + return nil, fmt.Errorf("invalid SERVER_PORT: %w", err) + } + + if cfg.PlatformURL == "" { + return nil, fmt.Errorf("environment variable DT_PLATFORM_URL is required") + } + u, err := url.Parse(cfg.PlatformURL) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return nil, fmt.Errorf("DT_PLATFORM_URL must be an http(s) URL with a host, got: %q", cfg.PlatformURL) + } + + timeout, err := time.ParseDuration(getEnv("DT_QUERY_TIMEOUT", "30s")) + if err != nil || timeout <= 0 { + return nil, fmt.Errorf("DT_QUERY_TIMEOUT must be a positive duration such as 30s") + } + cfg.QueryTimeout = timeout + + switch cfg.AuthMode { + case AuthModePlatformToken: + if cfg.PlatformToken == "" { + return nil, fmt.Errorf("environment variable DT_PLATFORM_TOKEN is required when DT_AUTH_MODE=%s", AuthModePlatformToken) + } + case AuthModeOAuth: + if cfg.OAuth.ClientID == "" || cfg.OAuth.ClientSecret == "" { + return nil, fmt.Errorf("DT_OAUTH_CLIENT_ID and DT_OAUTH_CLIENT_SECRET are required when DT_AUTH_MODE=%s", AuthModeOAuth) + } + default: + return nil, fmt.Errorf("DT_AUTH_MODE must be %q or %q, got %q", AuthModePlatformToken, AuthModeOAuth, cfg.AuthMode) + } + + if cfg.ContainerLogsSource == cfg.AuditLogsSource { + return nil, fmt.Errorf("DT_CONTAINER_LOGS_SOURCE and DT_AUDIT_LOGS_SOURCE must differ, both are %q", cfg.AuditLogsSource) + } + + return cfg, nil +} + +func parseLogLevel(level string) slog.Level { + switch strings.ToUpper(level) { + case "DEBUG": + return slog.LevelDebug + case "WARN", "WARNING": + return slog.LevelWarn + case "ERROR": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/observability-logs-dynatrace/internal/config_test.go b/observability-logs-dynatrace/internal/config_test.go new file mode 100644 index 00000000..46649c04 --- /dev/null +++ b/observability-logs-dynatrace/internal/config_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "log/slog" + "testing" + "time" +) + +func setEnv(t *testing.T, env map[string]string) { + t.Helper() + for _, k := range []string{ + "SERVER_PORT", "LOG_LEVEL", "DT_PLATFORM_URL", "DT_AUTH_MODE", "DT_PLATFORM_TOKEN", "DT_QUERY_TIMEOUT", + "DT_OAUTH_CLIENT_ID", "DT_OAUTH_CLIENT_SECRET", "DT_OAUTH_TOKEN_URL", "DT_OAUTH_SCOPE", "DT_OAUTH_RESOURCE", + "DT_CONTAINER_LOGS_SOURCE", "DT_AUDIT_LOGS_SOURCE", "DT_AUDIT_BUCKET", + } { + t.Setenv(k, "") + } + for k, v := range env { + t.Setenv(k, v) + } +} + +func TestLoadConfigDefaults(t *testing.T) { + setEnv(t, map[string]string{"DT_PLATFORM_URL": "https://abc.apps.dynatrace.com/", "DT_PLATFORM_TOKEN": "tok"}) + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.PlatformURL != "https://abc.apps.dynatrace.com" || cfg.ServerPort != "9098" || cfg.AuthMode != AuthModePlatformToken || + cfg.QueryTimeout != 30*time.Second || cfg.ContainerLogsSource != "openchoreo-container-logs" || + cfg.AuditLogsSource != "openchoreo-audit-logs" || cfg.LogLevel != slog.LevelInfo { + t.Errorf("unexpected defaults %+v", cfg) + } +} + +func TestLoadConfigOAuth(t *testing.T) { + setEnv(t, map[string]string{ + "DT_PLATFORM_URL": "https://abc.apps.dynatrace.com", "DT_AUTH_MODE": "oauth", + "DT_OAUTH_CLIENT_ID": "id", "DT_OAUTH_CLIENT_SECRET": "s", "DT_OAUTH_RESOURCE": "urn:dtaccount:x", + "LOG_LEVEL": "debug", "DT_QUERY_TIMEOUT": "45s", + }) + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.OAuth.ClientID != "id" || cfg.OAuth.Resource != "urn:dtaccount:x" || cfg.LogLevel != slog.LevelDebug || cfg.QueryTimeout != 45*time.Second { + t.Errorf("unexpected %+v", cfg) + } +} + +func TestLoadConfigErrors(t *testing.T) { + base := map[string]string{"DT_PLATFORM_URL": "https://abc.apps.dynatrace.com", "DT_PLATFORM_TOKEN": "tok"} + with := func(overrides map[string]string) map[string]string { + m := map[string]string{} + for k, v := range base { + m[k] = v + } + for k, v := range overrides { + m[k] = v + } + return m + } + for name, env := range map[string]map[string]string{ + "missing url": {"DT_PLATFORM_TOKEN": "tok"}, + "bad url": with(map[string]string{"DT_PLATFORM_URL": "abc.apps.dynatrace.com"}), + "ftp url": with(map[string]string{"DT_PLATFORM_URL": "ftp://abc"}), + "missing token": {"DT_PLATFORM_URL": "https://abc.apps.dynatrace.com"}, + "oauth no client": with(map[string]string{"DT_AUTH_MODE": "oauth"}), + "bad auth mode": with(map[string]string{"DT_AUTH_MODE": "basic"}), + "bad port": with(map[string]string{"SERVER_PORT": "http"}), + "bad timeout": with(map[string]string{"DT_QUERY_TIMEOUT": "soon"}), + "same sources": with(map[string]string{"DT_CONTAINER_LOGS_SOURCE": "x", "DT_AUDIT_LOGS_SOURCE": "x"}), + } { + t.Run(name, func(t *testing.T) { + setEnv(t, env) + if _, err := LoadConfig(); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestParseLogLevel(t *testing.T) { + for in, want := range map[string]slog.Level{"": slog.LevelInfo, "warning": slog.LevelWarn, "ERROR": slog.LevelError, "x": slog.LevelInfo} { + if got := parseLogLevel(in); got != want { + t.Errorf("parseLogLevel(%q) = %v", in, got) + } + } +} diff --git a/observability-logs-dynatrace/internal/dynatrace/audit.go b/observability-logs-dynatrace/internal/dynatrace/audit.go new file mode 100644 index 00000000..700e1cc5 --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/audit.go @@ -0,0 +1,528 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "time" + + "golang.org/x/sync/errgroup" +) + +const ( + // AuditEntitlementsFilter lists values across every entitlement claim. + AuditEntitlementsFilter = "actor.entitlements" + + maxTimelineBuckets = 500 + // Keeps count*week from overflowing a Duration. + maxTimelineIntervalCount = 10000 + // One row per bucket and result value. + maxTimelineRows = maxTimelineBuckets * 20 +) + +// AuditLogsParams holds the filters for an audit log query. +type AuditLogsParams struct { + StartTime time.Time + EndTime time.Time + + ActorIDs []string + ActorTypes []string + ActorIssuers []string + ActorSessionIDs []string + ActorEntitlements []string + + ResourceTypes []string + ResourceNamespaces []string + ResourceEnvironments []string + ResourceProjects []string + ResourceComponents []string + ResourceResources []string + ResourceNames []string + + Actions []string + Categories []string + Results []string + Producers []string + Surfaces []string + OperationIDs []string + RequestIDs []string + EventIDs []string + SourceIPs []string + UserAgents []string + + SearchPhrase string + Limit int + SortOrder string +} + +// AuditRecord is one audit event, decoded from the producer's log line. +type AuditRecord struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + EventTime time.Time `json:"event_time"` + Actor AuditActor `json:"actor"` + Action string `json:"action"` + Category string `json:"category"` + Result string `json:"result"` + RequestID string `json:"request_id"` + SourceIP string `json:"source_ip"` + UserAgent string `json:"user_agent"` + Producer string `json:"producer"` + Surface string `json:"surface"` + OperationID string `json:"operation_id"` + HTTP *AuditHTTPInfo `json:"http"` + Resource *AuditResource `json:"resource"` + Metadata map[string]any `json:"metadata"` + + // Read from the collector's fields, never from the producer's line. + Collector AuditCollectorInfo `json:"-"` +} + +// AuditActor is who performed the action. ID is unique only within Issuer. +type AuditActor struct { + Type string `json:"type"` + ID string `json:"id"` + Issuer string `json:"issuer"` + SessionID string `json:"session_id"` + Entitlements map[string][]string `json:"entitlements"` +} + +// AuditHTTPInfo is the request line of an event that arrived over HTTP. +type AuditHTTPInfo struct { + Method string `json:"method"` + Path string `json:"path"` +} + +// AuditResource is the target resource and where authorization was decided. +type AuditResource struct { + Type string `json:"type"` + Namespace string `json:"namespace"` + Environment string `json:"environment"` + Project string `json:"project"` + Component string `json:"component"` + Resource string `json:"resource"` + UID string `json:"uid"` + Name string `json:"name"` + Metadata map[string]any `json:"metadata"` +} + +// AuditCollectorInfo is where the collector read the record from. +type AuditCollectorInfo struct { + NamespaceName string + PodName string + ContainerName string +} + +// AuditLogsResult is a page of audit records with the total and optional timeline. +type AuditLogsResult struct { + Records []AuditRecord + Total int64 + Took int + Timeline *AuditTimeline +} + +// AuditTimeline is per-interval counts across the queried window. +type AuditTimeline struct { + Interval string + Buckets []AuditTimelineBucket +} + +// AuditTimelineBucket is one interval of the timeline. +type AuditTimelineBucket struct { + StartTime time.Time + Total int64 + Counts map[string]int64 +} + +// auditFilterFields maps contract filters to the fields the Lua filter lifts them into. +var auditFilterFields = map[string]string{ + "actor.id": auActorID, + "actor.type": auActorType, + "actor.issuer": auActorIssuer, + "actor.session_id": auActorSessionID, + "actor.entitlements": auActorEntitlements, + "resource.type": auResourceType, + "resource.namespace": auResourceNamespace, + "resource.environment": auResourceEnvironment, + "resource.project": auResourceProject, + "resource.component": auResourceComponent, + "resource.resource": auResourceResource, + "resource.name": auResourceName, + "action": auAction, + "category": auCategory, + "result": auResult, + "producer": auProducer, + "surface": auSurface, + "operation_id": auOperationID, + "source_ip": auSourceIP, + "user_agent": auUserAgent, +} + +// IsAuditFilter reports whether a filter's values can be listed. +func IsAuditFilter(filter string) bool { + _, ok := auditFilterFields[filter] + return ok +} + +// auditScope limits a query to audit records. +func (c *Client) auditScope() string { + return eq(fLogSource, c.auditSource) +} + +// auditFetch starts an audit query, reading only the audit bucket when one is configured. +func (c *Client) auditFetch() *pipeline { + return fetchLogsFrom(c.auditBucket) +} + +func auditConditions(p AuditLogsParams) string { + conditions := []string{ + anyOf(auActorID, p.ActorIDs), + anyOf(auActorType, p.ActorTypes), + anyOf(auActorIssuer, p.ActorIssuers), + anyOf(auActorSessionID, p.ActorSessionIDs), + arrayHasAny(auActorEntitlements, p.ActorEntitlements), + anyOf(auResourceType, p.ResourceTypes), + anyOf(auResourceNamespace, p.ResourceNamespaces), + anyOf(auResourceEnvironment, p.ResourceEnvironments), + anyOf(auResourceProject, p.ResourceProjects), + anyOf(auResourceComponent, p.ResourceComponents), + anyOf(auResourceResource, p.ResourceResources), + anyOf(auResourceName, p.ResourceNames), + anyOf(auAction, p.Actions), + anyOf(auCategory, p.Categories), + anyOf(auResult, p.Results), + anyOf(auProducer, p.Producers), + anyOf(auSurface, p.Surfaces), + anyOf(auOperationID, p.OperationIDs), + anyOf(auRequestID, p.RequestIDs), + anyOf(auEventID, p.EventIDs), + anyOf(auSourceIP, p.SourceIPs), + anyOf(auUserAgent, p.UserAgents), + } + if p.SearchPhrase != "" { + // Case-sensitive: an audit search is for an exact identifier or phrase, and the + // contract's records are frozen text a SIEM compares byte for byte. + conditions = append(conditions, containsText(fContent, p.SearchPhrase, true)) + } + return and(conditions...) +} + +var auditFields = []string{fTimestamp, fContent, fNamespaceName, fPodName, fContainerName} + +// GenerateAuditLogsQueries returns the page query and the count query for audit logs. +func (c *Client) GenerateAuditLogsQueries(p AuditLogsParams) (records, count string) { + conditions := auditConditions(p) + scope := c.auditScope() + dir := sortDirection(p.SortOrder) + + // event_id breaks ties, so paging by the last record's time is stable. + records = c.auditFetch(). + filter(scope). + filter(conditions). + then("sort " + field(fTimestamp) + " " + dir + ", " + field(auEventID) + " " + dir). + then("limit " + strconv.Itoa(clampLimit(p.Limit))). + then("fields " + fieldList(auditFields)). + String() + count = c.auditFetch(). + filter(scope). + filter(conditions). + then("summarize total = count()"). + String() + return records, count +} + +// GenerateAuditTimelineQuery counts records per bucket and result. Buckets are numbered +// from the window start rather than taken from bin(), which aligns to the epoch and would +// leave the first and last buckets straddling the window's edges. +func (c *Client) GenerateAuditTimelineQuery(p AuditLogsParams, width time.Duration) string { + bucket := "toLong(floor((unixMillisFromTimestamp(" + field(fTimestamp) + ") - " + + strconv.FormatInt(p.StartTime.UnixMilli(), 10) + ") / " + + strconv.FormatInt(width.Milliseconds(), 10) + "))" + return c.auditFetch(). + filter(c.auditScope()). + filter(auditConditions(p)). + then("summarize record_count = count(), by: {bucket = " + bucket + ", result = " + field(auResult) + "}"). + String() +} + +// GetAuditLogs returns a page of audit records, their total and, when timelineInterval is +// set, the timeline. +func (c *Client) GetAuditLogs(ctx context.Context, p AuditLogsParams, timelineInterval string) (*AuditLogsResult, error) { + recordsQuery, countQuery := c.GenerateAuditLogsQueries(p) + + var page *QueryResult + var total int64 + var timeline *AuditTimeline + + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + res, t, err := c.runPageAndCount(gctx, recordsQuery, countQuery, p.StartTime, p.EndTime, clampLimit(p.Limit)) + page, total = res, t + return err + }) + if timelineInterval != "" { + g.Go(func() error { + // The contract allows omitting a failed timeline rather than failing the query. + t, err := c.getAuditTimeline(gctx, p, timelineInterval) + if err != nil { + c.logger.Warn("Failed to compute audit timeline", slog.Any("error", err)) + return nil + } + timeline = t + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + + records := make([]AuditRecord, 0, len(page.Records)) + for _, r := range page.Records { + record, err := ParseAuditRecord(r) + if err != nil { + c.logger.Warn("Skipping malformed audit record", slog.Any("error", err)) + continue + } + records = append(records, record) + } + + return &AuditLogsResult{ + Records: records, + Total: total, + Took: int(page.Took.Milliseconds()), + Timeline: timeline, + }, nil +} + +func (c *Client) getAuditTimeline(ctx context.Context, p AuditLogsParams, interval string) (*AuditTimeline, error) { + width, err := parseTimelineInterval(interval) + if err != nil { + return nil, err + } + res, err := c.Query(ctx, QueryRequest{ + Query: c.GenerateAuditTimelineQuery(p, width), Start: p.StartTime, End: p.EndTime, + MaxRecords: maxTimelineRows, + }) + if err != nil { + return nil, err + } + return buildAuditTimeline(interval, p.StartTime, p.EndTime, res.Records) +} + +// GenerateAuditFilterValuesQueries returns the values query and its total query. +func (c *Client) GenerateAuditFilterValuesQueries( + p AuditLogsParams, filter, valueSearch string, maxValues int, +) (values, total string, err error) { + valueField, ok := auditFilterFields[filter] + if !ok { + return "", "", fmt.Errorf("unknown audit filter %q", filter) + } + + if filter != AuditEntitlementsFilter { + values, total = buildValuesQueries(c.auditFetch, c.auditScope(), auditConditions(p), valueField, valueSearch, maxValues) + return values, total, nil + } + + // Entitlements are an array per record: expand it to one row per value, deduplicated + // first so a record carrying a value under two claims still counts once. + valueFilter := `isNotNull(value) and value != ""` + if valueSearch != "" { + valueFilter = and(valueFilter, "contains(value, "+str(valueSearch)+", caseSensitive: false)") + } + base := func() *pipeline { + return c.auditFetch(). + filter(c.auditScope()). + filter(auditConditions(p)). + then("fieldsAdd value = arrayDistinct(" + field(valueField) + ")"). + then("expand value"). + filter(valueFilter) + } + values = base(). + then("summarize record_count = count(), by: {value}"). + then("sort record_count desc, value asc"). + then("limit " + strconv.Itoa(maxValues)). + String() + total = base(). + then("summarize total = countDistinctExact(value)"). + String() + return values, total, nil +} + +// GetAuditLogFilterValues lists the distinct values a filter takes. params must already +// exclude the filter's own selections. +func (c *Client) GetAuditLogFilterValues( + ctx context.Context, p AuditLogsParams, filter, valueSearch string, maxValues int, +) (*FilterValues, error) { + valuesQuery, totalQuery, err := c.GenerateAuditFilterValuesQueries(p, filter, valueSearch, maxValues) + if err != nil { + return nil, err + } + return c.runValuesQueries(ctx, valuesQuery, totalQuery, p.StartTime, p.EndTime, maxValues) +} + +// ParseAuditRecord decodes a record's original log line rather than its lifted fields, so +// nested metadata survives exactly as the producer wrote it. A record missing identity +// fields is an error rather than a zero-valued record. +func ParseAuditRecord(row map[string]any) (AuditRecord, error) { + line := stringField(row, fContent) + if line == "" { + return AuditRecord{}, fmt.Errorf("record has no content") + } + + var record AuditRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + return AuditRecord{}, fmt.Errorf("failed to parse audit line: %w", err) + } + + // action and category are not required: a record with no resolved operation carries + // them empty. + for _, required := range []struct{ field, value string }{ + {"schema_version", record.SchemaVersion}, + {"event_id", record.EventID}, + {"actor.type", record.Actor.Type}, + {"actor.id", record.Actor.ID}, + {"result", record.Result}, + } { + if required.value == "" { + return AuditRecord{}, fmt.Errorf("record has no %s", required.field) + } + } + if record.EventTime.IsZero() { + return AuditRecord{}, fmt.Errorf("record has no event_time") + } + + record.Collector = AuditCollectorInfo{ + NamespaceName: stringField(row, fNamespaceName), + PodName: stringField(row, fPodName), + ContainerName: stringField(row, fContainerName), + } + return record, nil +} + +// buildAuditTimeline zero-fills the buckets the query returns no rows for. +func buildAuditTimeline(interval string, start, end time.Time, rows []map[string]any) (*AuditTimeline, error) { + width, err := parseTimelineInterval(interval) + if err != nil { + return nil, err + } + window := end.Sub(start) + if width <= 0 || window <= 0 { + return nil, fmt.Errorf("invalid timeline window") + } + + count := int((window + width - 1) / width) + buckets := make([]AuditTimelineBucket, count) + for i := range buckets { + buckets[i] = AuditTimelineBucket{ + StartTime: start.Add(time.Duration(i) * width), + Counts: map[string]int64{}, + } + } + + for _, row := range rows { + index, ok := int64Field(row, "bucket") + if !ok || index < 0 || index >= int64(count) { + continue + } + n, _ := int64Field(row, "record_count") + b := &buckets[index] + b.Total += n + if result := stringField(row, "result"); result != "" { + b.Counts[result] += n + } + } + + return &AuditTimeline{Interval: interval, Buckets: buckets}, nil +} + +// ResolveTimelineInterval picks the bucket width, coarsening rather than rejecting one +// that exceeds the bucket limit. +func ResolveTimelineInterval(requested string, start, end time.Time) (string, error) { + window := end.Sub(start) + if window <= 0 { + return "", fmt.Errorf("end time must be after start time") + } + + width, err := parseTimelineInterval(requested) + if err != nil { + return "", err + } + if width <= 0 { + width = (window / 60).Truncate(time.Minute) + if width < time.Minute { + width = time.Minute + } + } + + for (window+width-1)/width > maxTimelineBuckets { + next := coarsenInterval(width) + if next <= width { + width += 7 * 24 * time.Hour + continue + } + width = next + } + + return formatTimelineInterval(width), nil +} + +// parseTimelineInterval reads notation; empty means the adapter chooses. +func parseTimelineInterval(s string) (time.Duration, error) { + if s == "" { + return 0, nil + } + if len(s) < 2 { + return 0, fmt.Errorf("invalid timeline interval: %s", s) + } + + count, err := strconv.Atoi(s[:len(s)-1]) + if err != nil || count <= 0 || count > maxTimelineIntervalCount { + return 0, fmt.Errorf("invalid timeline interval: %s", s) + } + + var unit time.Duration + switch s[len(s)-1] { + case 'm': + unit = time.Minute + case 'h': + unit = time.Hour + case 'd': + unit = 24 * time.Hour + case 'w': + unit = 7 * 24 * time.Hour + default: + return 0, fmt.Errorf("invalid timeline interval unit: %s", s) + } + + return time.Duration(count) * unit, nil +} + +func coarsenInterval(d time.Duration) time.Duration { + switch { + case d < time.Hour: + return time.Hour + case d < 24*time.Hour: + return 24 * time.Hour + case d < 7*24*time.Hour: + return 7 * 24 * time.Hour + } + return d +} + +func formatTimelineInterval(d time.Duration) string { + switch { + case d%(7*24*time.Hour) == 0: + return fmt.Sprintf("%dw", d/(7*24*time.Hour)) + case d%(24*time.Hour) == 0: + return fmt.Sprintf("%dd", d/(24*time.Hour)) + case d%time.Hour == 0: + return fmt.Sprintf("%dh", d/time.Hour) + default: + return fmt.Sprintf("%dm", d/time.Minute) + } +} diff --git a/observability-logs-dynatrace/internal/dynatrace/auth.go b/observability-logs-dynatrace/internal/dynatrace/auth.go new file mode 100644 index 00000000..19bda9ab --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/auth.go @@ -0,0 +1,138 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// DefaultOAuthTokenURL is the Dynatrace SSO token endpoint for SaaS environments. +const DefaultOAuthTokenURL = "https://sso.dynatrace.com/sso/oauth2/token" + +// DefaultOAuthScope is the smallest scope set the adapter's queries need. +const DefaultOAuthScope = "storage:logs:read storage:buckets:read" + +// tokenRefreshMargin renews an OAuth token this long before it expires, so a query never +// starts with a token that lapses mid-flight. +const tokenRefreshMargin = 60 * time.Second + +// TokenSource yields the bearer token sent with each query. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// StaticToken is a Dynatrace platform token, which does not expire on its own. +type StaticToken string + +// Token returns the platform token. +func (t StaticToken) Token(context.Context) (string, error) { + if t == "" { + return "", fmt.Errorf("platform token is empty") + } + return string(t), nil +} + +// OAuthConfig configures the client-credentials grant against Dynatrace SSO. +type OAuthConfig struct { + TokenURL string + ClientID string + ClientSecret string + Scope string + // Resource is the optional account or environment URN, e.g. urn:dtaccount:. + Resource string +} + +// OAuthTokenSource exchanges OAuth client credentials for access tokens and caches them +// until shortly before they expire. +type OAuthTokenSource struct { + cfg OAuthConfig + httpClient *http.Client + now func() time.Time + + mu sync.Mutex + token string + expires time.Time +} + +// NewOAuthTokenSource returns a token source for the client-credentials grant. +func NewOAuthTokenSource(cfg OAuthConfig, httpClient *http.Client) *OAuthTokenSource { + if cfg.TokenURL == "" { + cfg.TokenURL = DefaultOAuthTokenURL + } + if cfg.Scope == "" { + cfg.Scope = DefaultOAuthScope + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 15 * time.Second} + } + return &OAuthTokenSource{cfg: cfg, httpClient: httpClient, now: time.Now} +} + +// Token returns a cached access token, fetching a new one when it is missing or close +// to expiry. +func (s *OAuthTokenSource) Token(ctx context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.token != "" && s.now().Before(s.expires.Add(-tokenRefreshMargin)) { + return s.token, nil + } + + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {s.cfg.ClientID}, + "client_secret": {s.cfg.ClientSecret}, + "scope": {s.cfg.Scope}, + } + if s.cfg.Resource != "" { + form.Set("resource", s.cfg.Resource) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("failed to create token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to call token endpoint: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read token response: %w", err) + } + if resp.StatusCode != http.StatusOK { + msg := string(body) + if len(msg) > maxErrorBody { + msg = msg[:maxErrorBody] + } + return "", fmt.Errorf("token endpoint returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) + } + + var parsed struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("failed to parse token response: %w", err) + } + if parsed.AccessToken == "" { + return "", fmt.Errorf("token response carried no access_token") + } + + s.token = parsed.AccessToken + s.expires = s.now().Add(time.Duration(parsed.ExpiresIn) * time.Second) + return s.token, nil +} diff --git a/observability-logs-dynatrace/internal/dynatrace/client.go b/observability-logs-dynatrace/internal/dynatrace/client.go new file mode 100644 index 00000000..fcb4c347 --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/client.go @@ -0,0 +1,281 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + executePath = "/platform/storage/query/v1/query:execute" + pollPath = "/platform/storage/query/v1/query:poll" + + // Query states reported by the Grail query API. + stateSucceeded = "SUCCEEDED" + stateRunning = "RUNNING" + stateNotStarted = "NOT_STARTED" + + // pollWait is how long one poll request asks Grail to hold the connection open while + // the query runs, so a slow query is waited on server-side rather than busy-polled. + pollWait = 5 * time.Second + + // maxErrorBody bounds how much of a failed response is carried into an error message. + maxErrorBody = 2048 +) + +// Config holds what the client needs to reach a Dynatrace environment. +type Config struct { + // PlatformURL is the environment's platform API base URL, e.g. + // https://abc12345.apps.dynatrace.com. + PlatformURL string + + // ContainerLogsSource is the log.source value Fluent Bit stamps on container logs. + ContainerLogsSource string + // AuditLogsSource is the log.source value Fluent Bit stamps on audit records. + AuditLogsSource string + // AuditBucket, when set, restricts audit queries to one Grail bucket. + AuditBucket string + + // QueryTimeout bounds the whole execute-and-poll cycle of one query. + QueryTimeout time.Duration +} + +// Client runs DQL queries against the Grail query API. +type Client struct { + baseURL string + tokens TokenSource + httpClient *http.Client + logger *slog.Logger + timeout time.Duration + logsSource string + auditSource string + auditBucket string +} + +// NewClient returns a client for the given environment. +func NewClient(cfg Config, tokens TokenSource, httpClient *http.Client, logger *slog.Logger) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 60 * time.Second} + } + timeout := cfg.QueryTimeout + if timeout <= 0 { + timeout = 30 * time.Second + } + return &Client{ + baseURL: strings.TrimRight(cfg.PlatformURL, "/"), + tokens: tokens, + httpClient: httpClient, + logger: logger, + timeout: timeout, + logsSource: cfg.ContainerLogsSource, + auditSource: cfg.AuditLogsSource, + auditBucket: cfg.AuditBucket, + } +} + +// QueryRequest is one DQL query over a time window. +type QueryRequest struct { + Query string + // Start and End bound the window as the query's default timeframe, so `fetch` needs no + // from/to of its own. Start is inclusive and End exclusive. + Start time.Time + End time.Time + // MaxRecords caps the records Grail returns. Zero leaves Grail's default in place. + MaxRecords int +} + +// QueryResult is the records a query returned. +type QueryResult struct { + Records []map[string]any + Took time.Duration +} + +type executeRequest struct { + Query string `json:"query"` + DefaultTimeframeStart string `json:"defaultTimeframeStart,omitempty"` + DefaultTimeframeEnd string `json:"defaultTimeframeEnd,omitempty"` + Timezone string `json:"timezone"` + Locale string `json:"locale"` + MaxResultRecords int `json:"maxResultRecords,omitempty"` + RequestTimeoutMilliseconds int64 `json:"requestTimeoutMilliseconds"` +} + +type queryResponse struct { + State string `json:"state"` + RequestToken string `json:"requestToken"` + Result *struct { + Records []map[string]any `json:"records"` + } `json:"result"` +} + +type errorEnvelope struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Details struct { + ErrorMessage string `json:"errorMessage"` + } `json:"details"` + } `json:"error"` +} + +// Query runs one DQL query to completion and returns its records. +// +// Grail answers either with the result, or with a request token when the query outlives +// the initial request; in that case the token is polled until the query settles or the +// client's query timeout expires. +func (c *Client) Query(ctx context.Context, req QueryRequest) (*QueryResult, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + started := time.Now() + c.logger.Debug("Executing DQL query", slog.String("query", req.Query), + slog.Time("start", req.Start), slog.Time("end", req.End)) + + body := executeRequest{ + Query: req.Query, + Timezone: "UTC", + Locale: "en_US", + MaxResultRecords: req.MaxRecords, + RequestTimeoutMilliseconds: pollWait.Milliseconds(), + } + if !req.Start.IsZero() { + body.DefaultTimeframeStart = req.Start.UTC().Format(time.RFC3339Nano) + } + if !req.End.IsZero() { + body.DefaultTimeframeEnd = req.End.UTC().Format(time.RFC3339Nano) + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal query request: %w", err) + } + + resp, err := c.do(ctx, http.MethodPost, c.baseURL+executePath, payload) + if err != nil { + return nil, err + } + + for resp.State == stateRunning || resp.State == stateNotStarted { + if resp.RequestToken == "" { + return nil, fmt.Errorf("dynatrace query is %s but returned no request token", resp.State) + } + pollURL := c.baseURL + pollPath + "?" + url.Values{ + "request-token": {resp.RequestToken}, + "request-timeout-milliseconds": {fmt.Sprint(pollWait.Milliseconds())}, + }.Encode() + resp, err = c.do(ctx, http.MethodGet, pollURL, nil) + if err != nil { + return nil, err + } + } + + if resp.State != stateSucceeded { + return nil, fmt.Errorf("dynatrace query finished in state %q", resp.State) + } + + result := &QueryResult{Records: []map[string]any{}, Took: time.Since(started)} + if resp.Result != nil && resp.Result.Records != nil { + result.Records = resp.Result.Records + } + return result, nil +} + +// Ping runs a minimal logs query so that a missing scope or a bad token fails at startup +// rather than on the first user request. +func (c *Client) Ping(ctx context.Context) error { + end := time.Now() + _, err := c.Query(ctx, QueryRequest{ + Query: "fetch logs | limit 1 | fields timestamp", + Start: end.Add(-time.Minute), + End: end, + MaxRecords: 1, + }) + return err +} + +func (c *Client) do(ctx context.Context, method, endpoint string, payload []byte) (*queryResponse, error) { + token, err := c.tokens.Token(ctx) + if err != nil { + return nil, fmt.Errorf("failed to obtain dynatrace access token: %w", err) + } + + var reqBody io.Reader + if payload != nil { + reqBody = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call dynatrace query API: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read dynatrace response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return nil, apiError(resp.StatusCode, respBody) + } + + var parsed queryResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("failed to parse dynatrace response: %w", err) + } + return &parsed, nil +} + +// APIError is a non-success answer from the Dynatrace API. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("dynatrace API returned status %d: %s", e.StatusCode, e.Message) +} + +// IsAPIError reports whether err is an APIError with the given status code. +func IsAPIError(err error, statusCode int) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == statusCode +} + +func apiError(status int, body []byte) error { + var env errorEnvelope + msg := "" + if json.Unmarshal(body, &env) == nil { + msg = env.Error.Message + if env.Error.Details.ErrorMessage != "" { + msg += ": " + env.Error.Details.ErrorMessage + } + } + if msg == "" { + msg = string(body) + if len(msg) > maxErrorBody { + msg = msg[:maxErrorBody] + } + } + return &APIError{StatusCode: status, Message: strings.TrimSpace(msg)} +} diff --git a/observability-logs-dynatrace/internal/dynatrace/client_test.go b/observability-logs-dynatrace/internal/dynatrace/client_test.go new file mode 100644 index 00000000..f527e1e2 --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/client_test.go @@ -0,0 +1,485 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestQueryPollsUntilSucceeded(t *testing.T) { + var polls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case executePath: + var req executeRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.DefaultTimeframeStart != "2026-01-01T00:00:00Z" || req.DefaultTimeframeEnd != "2026-01-01T01:00:00Z" { + t.Errorf("unexpected timeframe %s - %s", req.DefaultTimeframeStart, req.DefaultTimeframeEnd) + } + if req.Timezone != "UTC" || req.MaxResultRecords != 7 { + t.Errorf("unexpected request %+v", req) + } + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"state":"RUNNING","requestToken":"tok/1"}`)) + case pollPath: + if r.URL.Query().Get("request-token") != "tok/1" { + t.Errorf("unexpected token %q", r.URL.Query().Get("request-token")) + } + if polls.Add(1) < 2 { + _, _ = w.Write([]byte(`{"state":"RUNNING","requestToken":"tok/1"}`)) + return + } + _, _ = w.Write([]byte(`{"state":"SUCCEEDED","result":{"records":[{"a":"1"}]}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := NewClient(Config{PlatformURL: srv.URL + "/"}, StaticToken("t"), srv.Client(), discardLogger()) + res, err := c.Query(context.Background(), QueryRequest{ + Query: "fetch logs", + Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, 1, 1, 1, 0, 0, 0, time.UTC), + MaxRecords: 7, + }) + if err != nil { + t.Fatal(err) + } + if len(res.Records) != 1 || res.Records[0]["a"] != "1" { + t.Errorf("unexpected records %v", res.Records) + } + if polls.Load() != 2 { + t.Errorf("expected 2 polls, got %d", polls.Load()) + } +} + +func TestQueryReportsAPIError(t *testing.T) { + f := newFakeGrail(t, func(string) ([]map[string]any, int) { return nil, http.StatusBadRequest }) + _, err := f.client().Query(context.Background(), QueryRequest{Query: "fetch logs"}) + if err == nil || !IsAPIError(err, http.StatusBadRequest) { + t.Fatalf("expected 400 APIError, got %v", err) + } + if !strings.Contains(err.Error(), "PARSE_ERROR: bad dql") { + t.Errorf("error should carry the Grail message: %v", err) + } +} + +func TestQueryFailedState(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"state":"FAILED"}`)) + })) + defer srv.Close() + c := NewClient(Config{PlatformURL: srv.URL}, StaticToken("t"), srv.Client(), discardLogger()) + if _, err := c.Query(context.Background(), QueryRequest{Query: "x"}); err == nil { + t.Fatal("expected error for FAILED state") + } +} + +func TestQueryRunningWithoutToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"state":"RUNNING"}`)) + })) + defer srv.Close() + c := NewClient(Config{PlatformURL: srv.URL}, StaticToken("t"), srv.Client(), discardLogger()) + if _, err := c.Query(context.Background(), QueryRequest{Query: "x"}); err == nil { + t.Fatal("expected error for RUNNING without token") + } +} + +func TestPing(t *testing.T) { + f := newFakeGrail(t, func(string) ([]map[string]any, int) { return nil, 0 }) + if err := f.client().Ping(context.Background()); err != nil { + t.Fatal(err) + } + if q := f.queryTexts(); len(q) != 1 || !strings.HasPrefix(q[0], "fetch logs") { + t.Errorf("unexpected ping query %v", q) + } +} + +func TestStaticTokenEmpty(t *testing.T) { + if _, err := StaticToken("").Token(context.Background()); err == nil { + t.Fatal("expected error") + } +} + +func TestOAuthTokenSourceCaches(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _ = r.ParseForm() + if r.Form.Get("grant_type") != "client_credentials" || r.Form.Get("client_id") != "id" || + r.Form.Get("client_secret") != "secret" || r.Form.Get("scope") != DefaultOAuthScope || + r.Form.Get("resource") != "urn:dtaccount:x" { + t.Errorf("unexpected form %v", r.Form) + } + _, _ = w.Write([]byte(`{"access_token":"at","expires_in":300}`)) + })) + defer srv.Close() + + s := NewOAuthTokenSource(OAuthConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "secret", Resource: "urn:dtaccount:x"}, srv.Client()) + now := time.Now() + s.now = func() time.Time { return now } + + for i := 0; i < 3; i++ { + tok, err := s.Token(context.Background()) + if err != nil || tok != "at" { + t.Fatalf("token %q err %v", tok, err) + } + } + if calls.Load() != 1 { + t.Errorf("expected cached token, endpoint called %d times", calls.Load()) + } + + // Inside the refresh margin the token is renewed. + now = now.Add(250 * time.Second) + if _, err := s.Token(context.Background()); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Errorf("expected refresh, endpoint called %d times", calls.Load()) + } +} + +func TestOAuthTokenSourceErrors(t *testing.T) { + for name, handler := range map[string]http.HandlerFunc{ + "status": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) }, + "no token": func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{}`)) }, + "bad json": func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{`)) }, + } { + t.Run(name, func(t *testing.T) { + srv := httptest.NewServer(handler) + defer srv.Close() + s := NewOAuthTokenSource(OAuthConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "s"}, srv.Client()) + if _, err := s.Token(context.Background()); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestNewOAuthTokenSourceDefaults(t *testing.T) { + s := NewOAuthTokenSource(OAuthConfig{}, nil) + if s.cfg.TokenURL != DefaultOAuthTokenURL || s.cfg.Scope != DefaultOAuthScope { + t.Errorf("defaults not applied: %+v", s.cfg) + } +} + +func TestGetComponentLogs(t *testing.T) { + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + if isCount(q) { + return total(42), 0 + } + return []map[string]any{{ + "timestamp": "2026-01-01T00:00:01.123456789Z", + "content": "WARN disk low", + "k8s.namespace.name": "dp-default", + "k8s.pod.name": "api-1", + "k8s.container.name": "main", + "openchoreo.namespace": "default", + "openchoreo.component": "api", + "openchoreo.component_uid": "c-1", + "openchoreo.project": "shop", + "openchoreo.project_uid": "p-1", + "openchoreo.environment": "dev", + "openchoreo.environment_uid": "e-1", + }}, 0 + }) + + res, err := f.client().GetComponentLogs(context.Background(), ComponentLogsParams{Namespace: "default"}) + if err != nil { + t.Fatal(err) + } + if res.TotalCount != 42 || len(res.Logs) != 1 { + t.Fatalf("unexpected result %+v", res) + } + l := res.Logs[0] + if l.LogLevel != "WARN" || l.ComponentName != "api" || l.PodNamespace != "dp-default" || l.EnvironmentUID != "e-1" { + t.Errorf("unexpected entry %+v", l) + } + if l.Timestamp.Nanosecond() != 123456789 { + t.Errorf("timestamp precision lost: %v", l.Timestamp) + } + if len(f.queryTexts()) != 2 { + t.Errorf("expected page and count queries, got %d", len(f.queryTexts())) + } +} + +func TestGetComponentLogsError(t *testing.T) { + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + if isCount(q) { + return nil, http.StatusBadRequest + } + return nil, 0 + }) + if _, err := f.client().GetComponentLogs(context.Background(), ComponentLogsParams{Namespace: "default"}); err == nil { + t.Fatal("expected error when the count query fails") + } + if _, err := f.client().GetComponentLogs(context.Background(), ComponentLogsParams{}); err == nil { + t.Fatal("expected error without namespace") + } +} + +func TestGetWorkflowLogs(t *testing.T) { + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + if isCount(q) { + return total(1), 0 + } + return []map[string]any{{"timestamp": "2026-01-01T00:00:01Z", "content": "step 1"}}, 0 + }) + res, err := f.client().GetWorkflowLogs(context.Background(), WorkflowLogsParams{Namespace: "default", WorkflowRunName: "r"}) + if err != nil { + t.Fatal(err) + } + if res.TotalCount != 1 || res.Logs[0].Log != "step 1" { + t.Errorf("unexpected result %+v", res) + } +} + +func TestGetPlatformLogsAndValues(t *testing.T) { + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + switch { + case strings.Contains(q, "countDistinctExact"): + return total(3), 0 + case isCount(q): + return total(9), 0 + case strings.Contains(q, "record_count"): + return []map[string]any{ + {"value": "ns-a", "record_count": "5"}, + {"value": "", "record_count": "1"}, + {"value": "ns-b", "record_count": 2.0}, + }, 0 + } + return []map[string]any{{ + "timestamp": "2026-01-01T00:00:01Z", + "content": "hello", + "k8s.cluster.name": "c1", + "k8s.namespace.name": "ns-a", + "k8s.pod.ip": "10.0.0.1", + "k8s.node.name": "node-1", + "container.image.name": "img:1", + "k8s.pod.labels": []any{"app=api", "openchoreo.dev/plane=dp", "broken"}, + }}, 0 + }) + c := f.client() + + res, err := c.GetPlatformLogs(context.Background(), PlatformLogsParams{}) + if err != nil { + t.Fatal(err) + } + if res.TotalCount != 9 || res.Logs[0].LogLevel != "INFO" || res.Logs[0].NodeName != "node-1" { + t.Errorf("unexpected result %+v", res) + } + if got := res.Logs[0].Labels; len(got) != 2 || got["openchoreo.dev/plane"] != "dp" { + t.Errorf("unexpected labels %v", got) + } + + values, err := c.GetPlatformLogFilterValues(context.Background(), PlatformLogsParams{}, fNamespaceName, "", 10) + if err != nil { + t.Fatal(err) + } + if values.TotalValues != 3 || len(values.Values) != 2 || values.Values[0].Count != 5 || values.Values[1].Count != 2 { + t.Errorf("unexpected values %+v", values) + } +} + +func eventRecord(ts, reason string) map[string]any { + return map[string]any{ + "timestamp": ts, + "content": "Started container", + "loglevel": "Normal", + "k8s.event.reason": reason, + "k8s.object.kind": "Pod", + "k8s.object.name": "api-1", + "k8s.namespace.name": "dp-ns", + "k8s.object.label.openchoreo.dev/component-uid": "c-1", + } +} + +func TestGetEventsExtendsTies(t *testing.T) { + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + switch { + case isCount(q): + return total(5), 0 + case strings.Contains(q, "toTimestamp"): + return []map[string]any{ + eventRecord("2026-01-01T00:00:02Z", "B"), + eventRecord("2026-01-01T00:00:02Z", "C"), + eventRecord("2026-01-01T00:00:02Z", "D"), + }, 0 + } + return []map[string]any{ + eventRecord("2026-01-01T00:00:01Z", "A"), + eventRecord("2026-01-01T00:00:02Z", "B"), + }, 0 + }) + + res, err := f.client().GetEvents(context.Background(), EventsQueryParams{ + Reasons: []string{"A", "B", "C", "D"}, Limit: 2, SortOrder: "asc", + }) + if err != nil { + t.Fatal(err) + } + var reasons []string + for _, e := range res.Events { + reasons = append(reasons, e.Reason) + } + if strings.Join(reasons, ",") != "A,B,C,D" { + t.Errorf("tie extension produced %v", reasons) + } + if res.TotalCount != 5 { + t.Errorf("total %d", res.TotalCount) + } + e := res.Events[0] + if e.Type != "Normal" || e.ObjectKind != "Pod" || e.ComponentID != "c-1" || e.ObjectNamespace != "dp-ns" { + t.Errorf("unexpected event %+v", e) + } +} + +func TestGetEventsNoExtensionWhenComplete(t *testing.T) { + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + if isCount(q) { + return total(2), 0 + } + return []map[string]any{eventRecord("2026-01-01T00:00:01Z", "A"), eventRecord("2026-01-01T00:00:02Z", "B")}, 0 + }) + res, err := f.client().GetEvents(context.Background(), EventsQueryParams{Reasons: []string{"A"}, Limit: 2}) + if err != nil { + t.Fatal(err) + } + if len(res.Events) != 2 || len(f.queryTexts()) != 2 { + t.Errorf("unexpected extension: %d events, %d queries", len(res.Events), len(f.queryTexts())) + } +} + +func TestExtendTiesKeepsPageWhenTieReadShort(t *testing.T) { + last := time.Date(2026, 1, 1, 0, 0, 2, 0, time.UTC) + page := []map[string]any{eventRecord("2026-01-01T00:00:02Z", "A"), eventRecord("2026-01-01T00:00:02Z", "B")} + got := extendTies(page, last, []map[string]any{eventRecord("2026-01-01T00:00:02Z", "A")}) + if len(got) != 2 { + t.Errorf("expected page unchanged, got %d records", len(got)) + } +} + +func TestEventType(t *testing.T) { + for in, want := range map[string]string{"Normal": "Normal", "INFO": "Normal", "NOTICE": "Normal", "Warning": "Warning", "WARN": "Warning", "NONE": ""} { + if got := eventType(map[string]any{"loglevel": in}); got != want { + t.Errorf("eventType(%q) = %q, want %q", in, got, want) + } + } + if got := eventType(map[string]any{"severity": "Warning"}); got != "Warning" { + t.Errorf("severity fallback: %q", got) + } +} + +const auditLine = `{"time":"2026-01-01T00:00:01Z","level":"INFO","msg":"AUDIT-LOG","schema_version":"1.0",` + + `"event_id":"01J","event_time":"2026-01-01T00:00:01Z","actor":{"type":"user","id":"alice",` + + `"entitlements":{"groups":["admins"]}},"action":"create_project","category":"management",` + + `"result":"success","resource":{"type":"project","namespace":"default","name":"shop","metadata":{"k":{"n":1}}},` + + `"http":{"method":"POST","path":"/api/v1/projects"},"metadata":{"a":"b"}}` + +func TestGetAuditLogsWithTimeline(t *testing.T) { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + switch { + case isCount(q): + return total(2), 0 + case strings.Contains(q, "bucket ="): + return []map[string]any{ + {"bucket": "0", "result": "success", "record_count": "1"}, + {"bucket": "2", "result": "denied", "record_count": "4"}, + {"bucket": "99", "result": "denied", "record_count": "4"}, + }, 0 + } + return []map[string]any{ + {"timestamp": "2026-01-01T00:00:01Z", "content": auditLine, "k8s.pod.name": "api-0", "k8s.container.name": "api-server"}, + {"timestamp": "2026-01-01T00:00:01Z", "content": `{"not":"audit"}`}, + }, 0 + }) + + res, err := f.client().GetAuditLogs(context.Background(), AuditLogsParams{ + StartTime: start, EndTime: start.Add(time.Hour), + }, "15m") + if err != nil { + t.Fatal(err) + } + if res.Total != 2 || len(res.Records) != 1 { + t.Fatalf("unexpected result: total %d records %d", res.Total, len(res.Records)) + } + r := res.Records[0] + if r.Actor.ID != "alice" || r.Resource.Name != "shop" || r.HTTP.Method != "POST" || r.Collector.PodName != "api-0" { + t.Errorf("unexpected record %+v", r) + } + if res.Timeline == nil || len(res.Timeline.Buckets) != 4 { + t.Fatalf("unexpected timeline %+v", res.Timeline) + } + if res.Timeline.Buckets[0].Total != 1 || res.Timeline.Buckets[2].Counts["denied"] != 4 || res.Timeline.Buckets[1].Total != 0 { + t.Errorf("unexpected buckets %+v", res.Timeline.Buckets) + } +} + +func TestGetAuditLogsTimelineFailureIsOmitted(t *testing.T) { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + switch { + case strings.Contains(q, "bucket ="): + return nil, http.StatusBadRequest + case isCount(q): + return total(0), 0 + } + return nil, 0 + }) + res, err := f.client().GetAuditLogs(context.Background(), AuditLogsParams{StartTime: start, EndTime: start.Add(time.Hour)}, "1h") + if err != nil { + t.Fatal(err) + } + if res.Timeline != nil { + t.Errorf("failed timeline should be omitted") + } +} + +func TestGetAuditLogFilterValues(t *testing.T) { + f := newFakeGrail(t, func(q string) ([]map[string]any, int) { + if isCount(q) { + return total(1), 0 + } + return []map[string]any{{"value": "alice", "record_count": "3"}}, 0 + }) + res, err := f.client().GetAuditLogFilterValues(context.Background(), AuditLogsParams{}, "actor.id", "", 10) + if err != nil { + t.Fatal(err) + } + if res.TotalValues != 1 || res.Values[0].Value != "alice" || res.Values[0].Count != 3 { + t.Errorf("unexpected %+v", res) + } + if _, err := f.client().GetAuditLogFilterValues(context.Background(), AuditLogsParams{}, "nope", "", 10); err == nil { + t.Error("expected error for unknown filter") + } +} + +func TestParseAuditRecordRejectsIncomplete(t *testing.T) { + for name, row := range map[string]map[string]any{ + "no content": {}, + "bad json": {"content": "{"}, + "no id": {"content": `{"schema_version":"1.0","event_time":"2026-01-01T00:00:00Z","actor":{"type":"user","id":"a"},"result":"success"}`}, + "no time": {"content": `{"schema_version":"1.0","event_id":"x","actor":{"type":"user","id":"a"},"result":"success"}`}, + } { + if _, err := ParseAuditRecord(row); err == nil { + t.Errorf("%s: expected error", name) + } + } +} + +func TestIsAuditFilter(t *testing.T) { + if !IsAuditFilter("actor.entitlements") || !IsAuditFilter("resource.name") || IsAuditFilter("event_id") { + t.Error("unexpected audit filter set") + } +} diff --git a/observability-logs-dynatrace/internal/dynatrace/dql.go b/observability-logs-dynatrace/internal/dynatrace/dql.go new file mode 100644 index 00000000..2a080e9c --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/dql.go @@ -0,0 +1,151 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "sort" + "strings" +) + +// dqlStringEscaper escapes the characters that would end or corrupt a DQL string literal. +var dqlStringEscaper = strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + "\n", `\n`, + "\r", `\r`, + "\t", `\t`, +) + +// str renders a DQL string literal. Every value that reaches a query from a request goes +// through here, so a caller cannot close the literal and append DQL of their own. +func str(value string) string { + return `"` + dqlStringEscaper.Replace(value) + `"` +} + +// field renders a field reference. Backticks let a name carry the dots, slashes and dashes +// the schema uses (k8s.namespace.name, k8s.object.label.openchoreo.dev/component-uid). +// +// Field names come from constants or from label keys that have already passed +// IsValidLabelKey, neither of which can contain a backtick; any that did is dropped rather +// than allowed to end the quoted name early. +func field(name string) string { + return "`" + strings.ReplaceAll(name, "`", "") + "`" +} + +// eq matches one field against one value. +func eq(name, value string) string { + return field(name) + " == " + str(value) +} + +// anyOf ORs equality on one field across values. An empty slice is not a filter and yields +// "". Plain equality is used rather than in() so the predicate reads the same on every +// Grail version. +func anyOf(name string, values []string) string { + if len(values) == 0 { + return "" + } + parts := make([]string, 0, len(values)) + for _, v := range values { + parts = append(parts, eq(name, v)) + } + if len(parts) == 1 { + return parts[0] + } + return "(" + strings.Join(parts, " or ") + ")" +} + +// arrayHasAny matches records whose array field holds any of the values. +func arrayHasAny(name string, values []string) string { + if len(values) == 0 { + return "" + } + parts := make([]string, 0, len(values)) + for _, v := range values { + parts = append(parts, "iAny("+field(name)+"[] == "+str(v)+")") + } + if len(parts) == 1 { + return parts[0] + } + return "(" + strings.Join(parts, " or ") + ")" +} + +// containsText matches a substring of a string field. +func containsText(name, text string, caseSensitive bool) string { + cs := "false" + if caseSensitive { + cs = "true" + } + return "contains(" + field(name) + ", " + str(text) + ", caseSensitive: " + cs + ")" +} + +// present matches records that carry a non-empty value in the field. +func present(name string) string { + return "isNotNull(" + field(name) + ") and " + field(name) + ` != ""` +} + +// and joins non-empty conditions. +func and(conditions ...string) string { + kept := make([]string, 0, len(conditions)) + for _, c := range conditions { + if c != "" { + kept = append(kept, c) + } + } + return strings.Join(kept, " and ") +} + +// sortedKeys returns a map's keys in order, so the same filters always produce the same +// query text: Go randomises map iteration, and a query that changes between calls cannot +// be compared in a test or matched in a log. +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// pipeline assembles a DQL query from a fetch, a filter and trailing commands. +type pipeline struct { + commands []string +} + +func fetchLogs() *pipeline { + return &pipeline{commands: []string{"fetch logs"}} +} + +// fetchLogsFrom reads only the named Grail bucket, which Grail can prune at scan time +// rather than filtering every record of every bucket. +func fetchLogsFrom(bucket string) *pipeline { + if bucket == "" { + return fetchLogs() + } + return &pipeline{commands: []string{"fetch logs, bucket: {" + str(bucket) + "}"}} +} + +func (p *pipeline) filter(condition string) *pipeline { + if condition != "" { + p.commands = append(p.commands, "filter "+condition) + } + return p +} + +func (p *pipeline) then(command string) *pipeline { + p.commands = append(p.commands, command) + return p +} + +func (p *pipeline) String() string { + return strings.Join(p.commands, "\n| ") +} + +// sortDirection whitelists a sort direction; it sits outside any literal, where an +// arbitrary string would be executable. +func sortDirection(order string) string { + if strings.EqualFold(order, "asc") { + return "asc" + } + return "desc" +} diff --git a/observability-logs-dynatrace/internal/dynatrace/dql_test.go b/observability-logs-dynatrace/internal/dynatrace/dql_test.go new file mode 100644 index 00000000..ed8aa4e0 --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/dql_test.go @@ -0,0 +1,347 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "strings" + "testing" + "time" +) + +func TestStrEscapesLiteral(t *testing.T) { + tests := map[string]string{ + `plain`: `"plain"`, + `say "hi"`: `"say \"hi\""`, + `back\slash`: `"back\\slash"`, + "line\nbreak\ttab": `"line\nbreak\ttab"`, + `") | fetch x`: `"\") | fetch x"`, + } + for in, want := range tests { + if got := str(in); got != want { + t.Errorf("str(%q) = %s, want %s", in, got, want) + } + } +} + +func TestFieldQuotesAndStripsBackticks(t *testing.T) { + if got := field("k8s.object.label.openchoreo.dev/component-uid"); got != "`k8s.object.label.openchoreo.dev/component-uid`" { + t.Errorf("unexpected field: %s", got) + } + if got := field("a`b"); got != "`ab`" { + t.Errorf("backtick not stripped: %s", got) + } +} + +func TestAnyOf(t *testing.T) { + if got := anyOf("f", nil); got != "" { + t.Errorf("empty slice should not filter, got %q", got) + } + if got := anyOf("f", []string{"a"}); got != "`f` == \"a\"" { + t.Errorf("single value: %s", got) + } + if got := anyOf("f", []string{"a", "b"}); got != "(`f` == \"a\" or `f` == \"b\")" { + t.Errorf("multi value: %s", got) + } +} + +func TestArrayHasAny(t *testing.T) { + if got := arrayHasAny("arr", nil); got != "" { + t.Errorf("empty slice should not filter, got %q", got) + } + if got := arrayHasAny("arr", []string{"x", "y"}); got != "(iAny(`arr`[] == \"x\") or iAny(`arr`[] == \"y\"))" { + t.Errorf("unexpected: %s", got) + } +} + +func TestAndSkipsEmpty(t *testing.T) { + if got := and("", "a", "", "b"); got != "a and b" { + t.Errorf("unexpected: %s", got) + } +} + +func TestSortDirectionWhitelist(t *testing.T) { + for in, want := range map[string]string{"asc": "asc", "ASC": "asc", "desc": "desc", "": "desc", "asc; drop": "desc"} { + if got := sortDirection(in); got != want { + t.Errorf("sortDirection(%q) = %s, want %s", in, got, want) + } + } +} + +func TestFetchLogsFrom(t *testing.T) { + if got := fetchLogsFrom("").String(); got != "fetch logs" { + t.Errorf("unexpected: %s", got) + } + if got := fetchLogsFrom("audit_365").String(); got != `fetch logs, bucket: {"audit_365"}` { + t.Errorf("unexpected: %s", got) + } +} + +func testClient() *Client { + return NewClient(Config{ + PlatformURL: "https://env.apps.dynatrace.com", + ContainerLogsSource: "oc-logs", + AuditLogsSource: "oc-audit", + QueryTimeout: time.Second, + }, StaticToken("t"), nil, discardLogger()) +} + +func TestComponentLogsQueries(t *testing.T) { + c := testClient() + records, count, err := c.GenerateComponentLogsQueries(ComponentLogsParams{ + Namespace: "default", + ProjectID: "p-1", + EnvironmentID: "e-1", + ComponentID: "c-1", + SearchPhrase: `"quoted"`, + Limit: 50, + SortOrder: "asc", + }) + if err != nil { + t.Fatal(err) + } + want := "fetch logs\n" + + "| filter `log.source` == \"oc-logs\"\n" + + "| filter `openchoreo.namespace` == \"default\" and `openchoreo.project_uid` == \"p-1\" and " + + "`openchoreo.environment_uid` == \"e-1\" and `openchoreo.component_uid` == \"c-1\" and " + + "contains(`content`, \"\\\"quoted\\\"\", caseSensitive: false)\n" + + "| sort `timestamp` asc\n" + + "| limit 50\n" + + "| fields `timestamp`, `content`, `k8s.namespace.name`, `k8s.pod.name`, `k8s.container.name`, " + + "`openchoreo.namespace`, `openchoreo.project`, `openchoreo.project_uid`, `openchoreo.component`, " + + "`openchoreo.component_uid`, `openchoreo.environment`, `openchoreo.environment_uid`" + if records != want { + t.Errorf("records query:\n%s\nwant:\n%s", records, want) + } + if !strings.HasSuffix(count, "| summarize total = count()") || strings.Contains(count, "limit") { + t.Errorf("count query: %s", count) + } +} + +func TestComponentLogsRequiresNamespace(t *testing.T) { + if _, _, err := testClient().GenerateComponentLogsQueries(ComponentLogsParams{}); err == nil { + t.Fatal("expected error") + } + if _, _, err := testClient().GenerateWorkflowLogsQueries(WorkflowLogsParams{}); err == nil { + t.Fatal("expected error") + } +} + +func TestWorkflowLogsQueries(t *testing.T) { + records, _, err := testClient().GenerateWorkflowLogsQueries(WorkflowLogsParams{ + Namespace: "default", WorkflowRunName: "build-1", LogLevels: []string{"ERROR"}, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "`k8s.namespace.name` == \"workflows-default\"", + "`openchoreo.workflow_run` == \"build-1\"", + "contains(`content`, \"error\", caseSensitive: false)", + "| limit 100", + "| sort `timestamp` desc", + } { + if !strings.Contains(records, want) { + t.Errorf("records query missing %q:\n%s", want, records) + } + } +} + +func TestClampLimit(t *testing.T) { + for in, want := range map[int]int{0: 100, -1: 100, 5: 5, 1000: 1000, 5000: 1000} { + if got := clampLimit(in); got != want { + t.Errorf("clampLimit(%d) = %d, want %d", in, got, want) + } + } +} + +func TestPlatformQueries(t *testing.T) { + c := testClient() + records, count := c.GeneratePlatformLogsQueries(PlatformLogsParams{ + ClusterInstances: []string{"c1", "c2"}, + Namespaces: []string{"ns"}, + Labels: map[string]string{"z": "1", "app.kubernetes.io/name": "api"}, + }) + for _, want := range []string{ + "(`k8s.cluster.name` == \"c1\" or `k8s.cluster.name` == \"c2\")", + "`k8s.namespace.name` == \"ns\"", + // sorted: app.kubernetes.io/name before z + "iAny(`k8s.pod.labels`[] == \"app.kubernetes.io/name=api\") and iAny(`k8s.pod.labels`[] == \"z=1\")", + "`k8s.pod.labels`", + } { + if !strings.Contains(records, want) { + t.Errorf("records query missing %q:\n%s", want, records) + } + } + if !strings.Contains(count, "summarize total = count()") { + t.Errorf("count query: %s", count) + } +} + +func TestPlatformFilterValuesQueries(t *testing.T) { + c := testClient() + values, total := c.GeneratePlatformFilterValuesQueries(PlatformLogsParams{Namespaces: []string{"ns"}}, fPodName, "api", 10) + for _, want := range []string{ + "isNotNull(`k8s.pod.name`) and `k8s.pod.name` != \"\"", + "contains(`k8s.pod.name`, \"api\", caseSensitive: false)", + "summarize record_count = count(), by: {value = `k8s.pod.name`}", + "sort record_count desc, value asc", + "limit 10", + } { + if !strings.Contains(values, want) { + t.Errorf("values query missing %q:\n%s", want, values) + } + } + if !strings.Contains(total, "countDistinctExact(`k8s.pod.name`)") { + t.Errorf("total query: %s", total) + } +} + +func TestPlatformFilterFieldAndClear(t *testing.T) { + if f, ok := PlatformFilterField("podName"); !ok || f != fPodName { + t.Errorf("podName -> %s %v", f, ok) + } + if _, ok := PlatformFilterField("labels"); ok { + t.Error("labels should not be listable") + } + p := PlatformLogsParams{ + ClusterInstances: []string{"a"}, Namespaces: []string{"b"}, PodNames: []string{"c"}, ContainerNames: []string{"d"}, + } + for _, f := range []string{"clusterInstance", "namespace", "podName", "containerName"} { + cleared := ClearFilterSelections(p, f) + n := len(cleared.ClusterInstances) + len(cleared.Namespaces) + len(cleared.PodNames) + len(cleared.ContainerNames) + if n != 3 { + t.Errorf("clearing %s left %d selections, want 3", f, n) + } + } +} + +func TestEventsQueries(t *testing.T) { + records, count, err := GenerateEventsQueries(EventsQueryParams{ + Component: &ComponentEventsScope{Namespace: "ns", ComponentID: "c-1"}, + Reasons: []string{"Started"}, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "isNotNull(`k8s.event.reason`)", + "`k8s.object.label.openchoreo.dev/namespace` == \"ns\"", + "`k8s.object.label.openchoreo.dev/component-uid` == \"c-1\"", + "`k8s.event.reason` == \"Started\"", + } { + if !strings.Contains(records, want) { + t.Errorf("records query missing %q:\n%s", want, records) + } + } + if strings.Contains(records, "log.source") { + t.Error("events are not scoped by log.source") + } + if !strings.Contains(count, "summarize total = count()") { + t.Errorf("count: %s", count) + } + + records, _, err = GenerateEventsQueries(EventsQueryParams{ + Workflow: &WorkflowEventsScope{Namespace: "ns", WorkflowRunName: "run-1", TaskName: "build"}, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "startsWith(`k8s.object.name`, \"run-1\")", + "`k8s.namespace.name` == \"workflows-ns\"", + "contains(`k8s.object.name`, \"build\", caseSensitive: true)", + } { + if !strings.Contains(records, want) { + t.Errorf("workflow query missing %q:\n%s", want, records) + } + } +} + +func TestEventsQueriesRejectInvalidScopes(t *testing.T) { + cases := []EventsQueryParams{ + {}, + {Component: &ComponentEventsScope{}}, + {Workflow: &WorkflowEventsScope{Namespace: "ns"}}, + } + for i, p := range cases { + if _, _, err := GenerateEventsQueries(p); err == nil { + t.Errorf("case %d: expected error", i) + } + } + if _, _, err := GenerateEventsQueries(EventsQueryParams{Reasons: []string{"X"}}); err != nil { + t.Errorf("unscoped with reasons should be allowed: %v", err) + } +} + +func TestAuditQueries(t *testing.T) { + c := testClient() + c.auditBucket = "audit_365" + records, count := c.GenerateAuditLogsQueries(AuditLogsParams{ + ActorIDs: []string{"alice"}, + ActorEntitlements: []string{"admins"}, + Results: []string{"denied"}, + SearchPhrase: "Create", + SortOrder: "asc", + Limit: 10, + }) + for _, want := range []string{ + `fetch logs, bucket: {"audit_365"}`, + "`log.source` == \"oc-audit\"", + "`audit.actor.id` == \"alice\"", + "iAny(`audit.actor.entitlements`[] == \"admins\")", + "`audit.result` == \"denied\"", + "contains(`content`, \"Create\", caseSensitive: true)", + "sort `timestamp` asc, `audit.event_id` asc", + "limit 10", + } { + if !strings.Contains(records, want) { + t.Errorf("records query missing %q:\n%s", want, records) + } + } + if !strings.HasPrefix(count, `fetch logs, bucket: {"audit_365"}`) { + t.Errorf("count query not bucket-scoped: %s", count) + } +} + +func TestAuditTimelineQuery(t *testing.T) { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + q := testClient().GenerateAuditTimelineQuery(AuditLogsParams{StartTime: start}, 15*time.Minute) + want := "summarize record_count = count(), by: {bucket = toLong(floor((unixMillisFromTimestamp(`timestamp`) - 1767225600000) / 900000)), result = `audit.result`}" + if !strings.Contains(q, want) { + t.Errorf("timeline query:\n%s\nwant to contain:\n%s", q, want) + } +} + +func TestAuditFilterValuesQueries(t *testing.T) { + c := testClient() + values, total, err := c.GenerateAuditFilterValuesQueries(AuditLogsParams{}, "action", "", 5) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(values, "by: {value = `audit.action`}") || !strings.Contains(total, "countDistinctExact(`audit.action`)") { + t.Errorf("unexpected queries:\n%s\n%s", values, total) + } + + values, total, err = c.GenerateAuditFilterValuesQueries(AuditLogsParams{}, AuditEntitlementsFilter, "adm", 5) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "fieldsAdd value = arrayDistinct(`audit.actor.entitlements`)", + "expand value", + "contains(value, \"adm\", caseSensitive: false)", + "summarize record_count = count(), by: {value}", + } { + if !strings.Contains(values, want) { + t.Errorf("entitlements values query missing %q:\n%s", want, values) + } + } + if !strings.Contains(total, "countDistinctExact(value)") { + t.Errorf("entitlements total: %s", total) + } + + if _, _, err := c.GenerateAuditFilterValuesQueries(AuditLogsParams{}, "event_id", "", 5); err == nil { + t.Error("event_id should not be listable") + } +} diff --git a/observability-logs-dynatrace/internal/dynatrace/events.go b/observability-logs-dynatrace/internal/dynatrace/events.go new file mode 100644 index 00000000..9410f0fd --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/events.go @@ -0,0 +1,236 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) + +// maxTieRecords bounds the follow-up read that completes a page ending mid-timestamp. A +// Kubernetes event timestamp has second granularity, so this many events in one second +// from one scope is far beyond anything a cluster produces. +const maxTieRecords = 10000 + +// EventsQueryParams holds the parameters of an events query. At most one of Component and +// Workflow is set; neither being set is an unscoped sweep, which must carry Reasons. +type EventsQueryParams struct { + Component *ComponentEventsScope + Workflow *WorkflowEventsScope + Reasons []string + StartTime time.Time + EndTime time.Time + Limit int + SortOrder string +} + +// ComponentEventsScope scopes events to an OpenChoreo namespace and, optionally, a +// project, component and environment within it. +type ComponentEventsScope struct { + Namespace string + ProjectID string + ComponentID string + EnvironmentID string +} + +// WorkflowEventsScope scopes events to a workflow run and, optionally, one of its tasks. +type WorkflowEventsScope struct { + Namespace string + WorkflowRunName string + TaskName string +} + +// EventEntry is one parsed Kubernetes event. +type EventEntry struct { + Timestamp time.Time + Message string + Type string + Reason string + ObjectKind string + ObjectName string + ObjectNamespace string + ComponentName string + ComponentID string + ProjectName string + ProjectID string + EnvironmentName string + EnvironmentID string + NamespaceName string +} + +// EventsResult is a page of events plus the number of events matching the query. +type EventsResult struct { + Events []EventEntry + TotalCount int + Took int +} + +func eventsConditions(p EventsQueryParams) (string, error) { + // Only records the k8s_events receiver produced carry an event reason, which is what + // keeps container logs out of an events query that has no other scope. + conditions := []string{"isNotNull(" + field(evReason) + ")"} + + switch { + case p.Component != nil: + s := p.Component + if s.Namespace == "" { + return "", fmt.Errorf("namespace is required for component event queries") + } + conditions = append(conditions, eq(evNamespaceName, s.Namespace)) + if s.ProjectID != "" { + conditions = append(conditions, eq(evProjectID, s.ProjectID)) + } + if s.ComponentID != "" { + conditions = append(conditions, eq(evComponentID, s.ComponentID)) + } + if s.EnvironmentID != "" { + conditions = append(conditions, eq(evEnvironmentID, s.EnvironmentID)) + } + case p.Workflow != nil: + s := p.Workflow + if s.Namespace == "" || s.WorkflowRunName == "" { + return "", fmt.Errorf("namespace and workflow run name are required for workflow event queries") + } + conditions = append(conditions, + "startsWith("+field(evObjectName)+", "+str(s.WorkflowRunName)+")", + eq(evObjectNamespace, workflowNamespacePrefix+s.Namespace), + ) + if s.TaskName != "" { + conditions = append(conditions, containsText(evObjectName, s.TaskName, true)) + } + default: + // An unscoped sweep is only ever narrowed by reasons. Refusing one without them + // here, as well as in the handler, means no code path can turn a missing scope + // into "every namespace". + if len(p.Reasons) == 0 { + return "", fmt.Errorf("an unscoped events query requires reasons") + } + } + + conditions = append(conditions, anyOf(evReason, p.Reasons)) + return and(conditions...), nil +} + +var eventsFields = []string{ + fTimestamp, fContent, fLogLevel, evSeverity, evReason, evObjectKind, evObjectName, evObjectNamespace, + evComponentName, evComponentID, evProjectName, evProjectID, + evEnvironmentName, evEnvironmentID, evNamespaceName, +} + +// GenerateEventsQueries returns the page query and the count query for events. +func GenerateEventsQueries(p EventsQueryParams) (records, count string, err error) { + conditions, err := eventsConditions(p) + if err != nil { + return "", "", err + } + return buildRecordsQuery("", conditions, p.SortOrder, clampLimit(p.Limit), eventsFields), + buildCountQuery("", conditions), nil +} + +// generateTieQuery reads every event at exactly one timestamp. +func generateTieQuery(conditions string, ts time.Time) string { + return fetchLogs(). + filter(conditions). + filter(field(fTimestamp) + " == toTimestamp(" + str(ts.UTC().Format(time.RFC3339Nano)) + ")"). + then("limit " + strconv.Itoa(maxTieRecords)). + then("fields " + fieldList(eventsFields)). + String() +} + +// GetEvents returns a page of events and the number of events matching the query. +// +// The page honours the contract's resumption rules: total is exact, sorting happens before +// truncation, and a page that would end part-way through the events sharing one timestamp +// is extended to cover all of them, so a caller resuming from the last timestamp neither +// skips events nor stalls on a timestamp holding more than a page of them. +func (c *Client) GetEvents(ctx context.Context, p EventsQueryParams) (*EventsResult, error) { + conditions, err := eventsConditions(p) + if err != nil { + return nil, err + } + limit := clampLimit(p.Limit) + recordsQuery := buildRecordsQuery("", conditions, p.SortOrder, limit, eventsFields) + countQuery := buildCountQuery("", conditions) + + page, total, err := c.runPageAndCount(ctx, recordsQuery, countQuery, p.StartTime, p.EndTime, limit) + if err != nil { + return nil, err + } + + records := page.Records + if len(records) == limit && total > int64(limit) { + last := timeField(records[len(records)-1], fTimestamp) + if !last.IsZero() { + ties, err := c.Query(ctx, QueryRequest{ + Query: generateTieQuery(conditions, last), Start: p.StartTime, End: p.EndTime, + MaxRecords: maxTieRecords, + }) + if err != nil { + return nil, fmt.Errorf("tie extension query failed: %w", err) + } + records = extendTies(records, last, ties.Records) + } + } + + events := make([]EventEntry, 0, len(records)) + for _, r := range records { + events = append(events, parseEvent(r)) + } + return &EventsResult{Events: events, TotalCount: int(total), Took: int(page.Took.Milliseconds())}, nil +} + +// extendTies replaces the page's trailing run of records at the last timestamp with the +// complete set read for that timestamp, so none of them is split across pages. +func extendTies(page []map[string]any, last time.Time, ties []map[string]any) []map[string]any { + cut := len(page) + for cut > 0 && timeField(page[cut-1], fTimestamp).Equal(last) { + cut-- + } + if len(ties) < len(page)-cut { + // The follow-up read saw fewer records than the page itself holds at that + // timestamp, so it cannot be the complete set; keep the page as it was. + return page + } + out := make([]map[string]any, 0, cut+len(ties)) + out = append(out, page[:cut]...) + return append(out, ties...) +} + +// eventType recovers the Kubernetes event type from the log record. The receiver carries +// Normal/Warning as the OTLP severity text; depending on ingest, Grail may hold it verbatim +// or normalised to a log level, so both spellings are mapped back. Grail normalises +// Normal to NOTICE. +func eventType(r map[string]any) string { + for _, key := range []string{fLogLevel, evSeverity} { + switch strings.ToUpper(stringField(r, key)) { + case "NORMAL", "INFO", "NOTICE": + return "Normal" + case "WARNING", "WARN": + return "Warning" + } + } + return "" +} + +func parseEvent(r map[string]any) EventEntry { + return EventEntry{ + Timestamp: timeField(r, fTimestamp), + Message: stringField(r, fContent), + Type: eventType(r), + Reason: stringField(r, evReason), + ObjectKind: stringField(r, evObjectKind), + ObjectName: stringField(r, evObjectName), + ObjectNamespace: stringField(r, evObjectNamespace), + ComponentName: stringField(r, evComponentName), + ComponentID: stringField(r, evComponentID), + ProjectName: stringField(r, evProjectName), + ProjectID: stringField(r, evProjectID), + EnvironmentName: stringField(r, evEnvironmentName), + EnvironmentID: stringField(r, evEnvironmentID), + NamespaceName: stringField(r, evNamespaceName), + } +} diff --git a/observability-logs-dynatrace/internal/dynatrace/fields.go b/observability-logs-dynatrace/internal/dynatrace/fields.go new file mode 100644 index 00000000..03aac067 --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/fields.go @@ -0,0 +1,96 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +// Grail field names this adapter reads. +// +// Container logs and audit records are shaped by the module's Fluent Bit Lua filter +// (helm/templates/fluent-bit/config.yaml), which flattens Fluent Bit's nested Kubernetes +// metadata into the flat keys below before shipping to the log ingest API. The two sides +// are one contract: a field renamed here must be renamed there. +// +// Kubernetes events are shipped by the observability-events-otel-collector module over +// OTLP, which Dynatrace stores with attribute keys unchanged. +const ( + fTimestamp = "timestamp" + fContent = "content" + fLogSource = "log.source" + fLogLevel = "loglevel" + + // Kubernetes coordinates, named as in the Dynatrace semantic dictionary. + fClusterName = "k8s.cluster.name" + fNamespaceName = "k8s.namespace.name" + fPodName = "k8s.pod.name" + fContainerName = "k8s.container.name" + fNodeName = "k8s.node.name" + fPodIP = "k8s.pod.ip" + fContainerImage = "container.image.name" + + // fPodLabels holds every pod label as a "key=value" string. An array of strings keeps + // each label key exactly as Kubernetes spells it, where turning keys into field names + // would have to mangle the "/" and "." they contain. + fPodLabels = "k8s.pod.labels" + + // OpenChoreo labels copied onto their own fields, so the component and workflow log + // queries filter on a plain field instead of searching the label array. + fOCNamespace = "openchoreo.namespace" + fOCProject = "openchoreo.project" + fOCProjectUID = "openchoreo.project_uid" + fOCComponent = "openchoreo.component" + fOCComponentUID = "openchoreo.component_uid" + fOCEnvironment = "openchoreo.environment" + fOCEnvironmentUID = "openchoreo.environment_uid" + fOCWorkflowRun = "openchoreo.workflow_run" +) + +// Kubernetes event fields, as written by the OTel k8s_events receiver and the +// k8seventenrich processor. +const ( + evReason = "k8s.event.reason" + evObjectKind = "k8s.object.kind" + evObjectName = "k8s.object.name" + evObjectNamespace = "k8s.namespace.name" + evSeverity = "severity" + + evLabelPrefix = "k8s.object.label.openchoreo.dev/" + evComponentName = evLabelPrefix + "component" + evComponentID = evLabelPrefix + "component-uid" + evProjectName = evLabelPrefix + "project" + evProjectID = evLabelPrefix + "project-uid" + evEnvironmentName = evLabelPrefix + "environment" + evEnvironmentID = evLabelPrefix + "environment-uid" + evNamespaceName = evLabelPrefix + "namespace" +) + +// Audit record fields. Every filterable field of the record is lifted to its own field +// under the "audit." prefix; the record itself is kept verbatim in content and parsed back +// from there, so nothing is lost to flattening. +const ( + auEventID = "audit.event_id" + auAction = "audit.action" + auCategory = "audit.category" + auResult = "audit.result" + auRequestID = "audit.request_id" + auSourceIP = "audit.source_ip" + auUserAgent = "audit.user_agent" + auProducer = "audit.producer" + auSurface = "audit.surface" + auOperationID = "audit.operation_id" + + auActorType = "audit.actor.type" + auActorID = "audit.actor.id" + auActorIssuer = "audit.actor.issuer" + auActorSessionID = "audit.actor.session_id" + // auActorEntitlements holds every value of every entitlement claim, so an entitlement + // filter matches regardless of which claim carries it. + auActorEntitlements = "audit.actor.entitlements" + + auResourceType = "audit.resource.type" + auResourceNamespace = "audit.resource.namespace" + auResourceEnvironment = "audit.resource.environment" + auResourceProject = "audit.resource.project" + auResourceComponent = "audit.resource.component" + auResourceResource = "audit.resource.resource" + auResourceName = "audit.resource.name" +) diff --git a/observability-logs-dynatrace/internal/dynatrace/helpers_test.go b/observability-logs-dynatrace/internal/dynatrace/helpers_test.go new file mode 100644 index 00000000..53b07fba --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/helpers_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// fakeGrail is an httptest server that answers query:execute with canned records chosen +// by a matcher over the DQL text, and records every query it receives. +type fakeGrail struct { + t *testing.T + srv *httptest.Server + mu sync.Mutex + queries []executeRequest + answer func(q string) ([]map[string]any, int) +} + +func newFakeGrail(t *testing.T, answer func(q string) ([]map[string]any, int)) *fakeGrail { + f := &fakeGrail{t: t, answer: answer} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != executePath { + http.NotFound(w, r) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer t" { + t.Errorf("unexpected Authorization header %q", got) + } + var req executeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("bad request body: %v", err) + } + f.mu.Lock() + f.queries = append(f.queries, req) + f.mu.Unlock() + + records, status := f.answer(req.Query) + if status != 0 && status != http.StatusOK { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"error":{"code":400,"message":"PARSE_ERROR","details":{"errorMessage":"bad dql"}}}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "state": stateSucceeded, + "result": map[string]any{"records": records}, + }) + })) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeGrail) client() *Client { + return NewClient(Config{ + PlatformURL: f.srv.URL, + ContainerLogsSource: "oc-logs", + AuditLogsSource: "oc-audit", + QueryTimeout: 5 * time.Second, + }, StaticToken("t"), f.srv.Client(), discardLogger()) +} + +func (f *fakeGrail) queryTexts() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, 0, len(f.queries)) + for _, q := range f.queries { + out = append(out, q.Query) + } + return out +} + +// isCount reports whether a query is a count or distinct-count query. +func isCount(q string) bool { + return strings.Contains(q, "summarize total") +} + +// total is a count query's answer. Grail serializes longs as strings. +func total(n int) []map[string]any { + return []map[string]any{{"total": strconv.Itoa(n)}} +} diff --git a/observability-logs-dynatrace/internal/dynatrace/level.go b/observability-logs-dynatrace/internal/dynatrace/level.go new file mode 100644 index 00000000..1ad6af9b --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/level.go @@ -0,0 +1,100 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import "strings" + +// logLevelMarkers are the substrings a message is scanned for, in precedence order, with +// the level each yields. "warning" is absent because "warn" precedes it and is a prefix of +// it, so it could never be reached. +var logLevelMarkers = []struct{ marker, level string }{ + {"error", "ERROR"}, + {"fatal", "FATAL"}, + {"severe", "SEVERE"}, + {"warn", "WARN"}, + {"info", "INFO"}, + {"debug", "DEBUG"}, +} + +// defaultLogLevel is the level of a message that carries no marker at all. +const defaultLogLevel = "INFO" + +// ExtractLogLevel reads a container log line's severity from its text. +// +// Fluent Bit emits no level for container logs, so a record's severity is only ever what +// its own message says. levelCondition is the query-side twin of this function and the +// two must agree, or a record could be filtered in as INFO and then displayed as ERROR. +func ExtractLogLevel(message string) string { + lower := strings.ToLower(message) + for _, m := range logLevelMarkers { + if strings.Contains(lower, m.marker) { + return m.level + } + } + return defaultLogLevel +} + +// levelCondition matches the records ExtractLogLevel would classify as the given level. +// +// Classification is first-match-wins down logLevelMarkers, so a level is selected by its +// own marker being present and every higher-precedence marker being absent: a line reading +// "INFO: retrying after ERROR" is an ERROR, and an INFO filter must not return it. Messages +// with no marker at all fall back to INFO, so the INFO filter selects those too. +func levelCondition(level string) string { + level = strings.ToUpper(strings.TrimSpace(level)) + if level == "" { + return "" + } + + var own []string + var higher []string + for _, m := range logLevelMarkers { + if m.level == level { + own = append(own, m.marker) + continue + } + if len(own) == 0 { + higher = append(higher, m.marker) + } + } + + var clauses []string + if len(own) > 0 { + parts := []string{containsText(fContent, own[0], false)} + for _, h := range higher { + parts = append(parts, "not "+containsText(fContent, h, false)) + } + clauses = append(clauses, "("+strings.Join(parts, " and ")+")") + } + + if level == defaultLogLevel { + absent := make([]string, 0, len(logLevelMarkers)) + for _, m := range logLevelMarkers { + absent = append(absent, "not "+containsText(fContent, m.marker, false)) + } + clauses = append(clauses, "("+strings.Join(absent, " and ")+")") + } + + if len(clauses) == 0 { + return "" + } + return "(" + strings.Join(clauses, " or ") + ")" +} + +// levelsCondition ORs levelCondition across the requested levels. +func levelsCondition(levels []string) string { + parts := make([]string, 0, len(levels)) + for _, l := range levels { + if c := levelCondition(l); c != "" { + parts = append(parts, c) + } + } + if len(parts) == 0 { + return "" + } + if len(parts) == 1 { + return parts[0] + } + return "(" + strings.Join(parts, " or ") + ")" +} diff --git a/observability-logs-dynatrace/internal/dynatrace/level_test.go b/observability-logs-dynatrace/internal/dynatrace/level_test.go new file mode 100644 index 00000000..10db6c32 --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/level_test.go @@ -0,0 +1,162 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestExtractLogLevel(t *testing.T) { + for in, want := range map[string]string{ + "INFO: retrying after ERROR": "ERROR", + "a warning happened": "WARN", + "debug details": "DEBUG", + "Fatal crash": "FATAL", + "nothing to see": "INFO", + "": "INFO", + } { + if got := ExtractLogLevel(in); got != want { + t.Errorf("ExtractLogLevel(%q) = %s, want %s", in, got, want) + } + } +} + +func TestLevelConditionPrecedence(t *testing.T) { + warn := levelCondition("warn") + for _, want := range []string{ + `contains(` + "`content`" + `, "warn", caseSensitive: false)`, + `not contains(` + "`content`" + `, "error", caseSensitive: false)`, + `not contains(` + "`content`" + `, "fatal", caseSensitive: false)`, + `not contains(` + "`content`" + `, "severe", caseSensitive: false)`, + } { + if !strings.Contains(warn, want) { + t.Errorf("WARN condition missing %q:\n%s", want, warn) + } + } + if strings.Contains(warn, `"info"`) { + t.Errorf("WARN must not exclude lower levels:\n%s", warn) + } + + info := levelCondition("INFO") + if strings.Count(info, " or ") != 1 || !strings.Contains(info, `not contains(`+"`content`"+`, "debug", caseSensitive: false)`) { + t.Errorf("INFO must also select unmarked messages:\n%s", info) + } + + if levelCondition("") != "" || levelCondition("TRACE") != "" { + t.Error("unknown or empty levels should not filter") + } +} + +func TestLevelsCondition(t *testing.T) { + if levelsCondition(nil) != "" { + t.Error("no levels should not filter") + } + if c := levelsCondition([]string{"ERROR", "WARN"}); !strings.HasPrefix(c, "((") || !strings.Contains(c, ") or (") { + t.Errorf("unexpected: %s", c) + } + if c := levelsCondition([]string{"ERROR"}); c != levelCondition("ERROR") { + t.Errorf("single level should not be re-wrapped: %s", c) + } +} + +func TestInt64Field(t *testing.T) { + r := map[string]any{"s": "12", "f": 3.0, "frac": 1.5, "n": json.Number("7"), "sf": "4.0", "bad": "x", "b": true} + for key, want := range map[string]int64{"s": 12, "f": 3, "n": 7, "sf": 4} { + if got, ok := int64Field(r, key); !ok || got != want { + t.Errorf("int64Field(%s) = %d %v, want %d", key, got, ok, want) + } + } + for _, key := range []string{"frac", "bad", "b", "missing"} { + if _, ok := int64Field(r, key); ok { + t.Errorf("int64Field(%s) should fail", key) + } + } +} + +func TestTimeField(t *testing.T) { + r := map[string]any{"ok": "2026-01-01T00:00:00.5+01:00", "bad": "yesterday", "num": 1.0} + if got := timeField(r, "ok"); !got.Equal(time.Date(2025, 12, 31, 23, 0, 0, 5e8, time.UTC)) || got.Location() != time.UTC { + t.Errorf("unexpected %v", got) + } + if !timeField(r, "bad").IsZero() || !timeField(r, "num").IsZero() { + t.Error("invalid timestamps should be zero") + } +} + +func TestSplitLabels(t *testing.T) { + got := splitLabels([]string{"a=1", "b=x=y", "=nokey", "novalue"}) + if len(got) != 2 || got["a"] != "1" || got["b"] != "x=y" { + t.Errorf("unexpected %v", got) + } + if splitLabels(nil) != nil || splitLabels([]string{"bad"}) != nil { + t.Error("expected nil") + } +} + +func TestStringSlice(t *testing.T) { + got := stringSlice(map[string]any{"a": []any{"x", 1.0, "y"}}, "a") + if len(got) != 2 { + t.Errorf("unexpected %v", got) + } + if stringSlice(map[string]any{"a": "x"}, "a") != nil { + t.Error("non-array should be nil") + } +} + +func TestIsValidLabelKey(t *testing.T) { + for _, k := range []string{"app", "openchoreo.dev/component-uid", "app.kubernetes.io/name", "A_b.c"} { + if !IsValidLabelKey(k) { + t.Errorf("%q should be valid", k) + } + } + for _, k := range []string{"", "a=b", "x`y", "-a", "a/", "/a", strings.Repeat("a", 64), strings.Repeat("a", 254) + "/b"} { + if IsValidLabelKey(k) { + t.Errorf("%q should be invalid", k) + } + } +} + +func TestResolveTimelineInterval(t *testing.T) { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + cases := []struct { + requested string + window time.Duration + want string + }{ + {"15m", time.Hour, "15m"}, + {"", time.Hour, "1m"}, + {"", 24 * time.Hour, "24m"}, + {"1m", 30 * 24 * time.Hour, "1d"}, + {"1m", 1000 * 24 * time.Hour, "1w"}, + {"1m", 4000 * 24 * time.Hour, "2w"}, + {"2h", 3 * time.Hour, "2h"}, + } + for _, c := range cases { + got, err := ResolveTimelineInterval(c.requested, start, start.Add(c.window)) + if err != nil || got != c.want { + t.Errorf("ResolveTimelineInterval(%q, %v) = %q %v, want %q", c.requested, c.window, got, err, c.want) + } + } + for _, bad := range []string{"x", "0m", "5s", "m", "10001m"} { + if _, err := ResolveTimelineInterval(bad, start, start.Add(time.Hour)); err == nil { + t.Errorf("%q should be rejected", bad) + } + } + if _, err := ResolveTimelineInterval("1m", start, start); err == nil { + t.Error("empty window should be rejected") + } +} + +func TestBuildAuditTimelineRejectsEmptyWindow(t *testing.T) { + start := time.Now() + if _, err := buildAuditTimeline("1m", start, start, nil); err == nil { + t.Error("expected error") + } + if _, err := buildAuditTimeline("bad", start, start.Add(time.Hour), nil); err == nil { + t.Error("expected error") + } +} diff --git a/observability-logs-dynatrace/internal/dynatrace/logs.go b/observability-logs-dynatrace/internal/dynatrace/logs.go new file mode 100644 index 00000000..0c8e7bdb --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/logs.go @@ -0,0 +1,290 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "context" + "fmt" + "strconv" + "time" + + "golang.org/x/sync/errgroup" +) + +const ( + defaultLimit = 100 + maxLimit = 1000 + + // workflowNamespacePrefix is how OpenChoreo names the Kubernetes namespace workflow + // runs of an OpenChoreo namespace execute in. + workflowNamespacePrefix = "workflows-" +) + +// ComponentLogsParams holds parameters for component log queries. +type ComponentLogsParams struct { + Namespace string + ProjectID string + EnvironmentID string + ComponentID string + StartTime time.Time + EndTime time.Time + SearchPhrase string + LogLevels []string + Limit int + SortOrder string +} + +// WorkflowLogsParams holds parameters for workflow log queries. +type WorkflowLogsParams struct { + Namespace string + WorkflowRunName string + StartTime time.Time + EndTime time.Time + SearchPhrase string + LogLevels []string + Limit int + SortOrder string +} + +// ComponentLogEntry is one parsed component log record. +type ComponentLogEntry struct { + Timestamp time.Time + Log string + LogLevel string + ComponentUID string + ComponentName string + EnvironmentUID string + EnvironmentName string + ProjectUID string + ProjectName string + Namespace string + PodName string + PodNamespace string + ContainerName string +} + +// ComponentLogsResult is a page of component logs plus the true total. +type ComponentLogsResult struct { + Logs []ComponentLogEntry + TotalCount int + Took int +} + +// WorkflowLogEntry is one parsed workflow log record. +type WorkflowLogEntry struct { + Timestamp time.Time + Log string +} + +// WorkflowLogsResult is a page of workflow logs plus the true total. +type WorkflowLogsResult struct { + Logs []WorkflowLogEntry + TotalCount int + Took int +} + +// clampLimit applies the contract's default and ceiling to a page size. +func clampLimit(limit int) int { + if limit <= 0 { + return defaultLimit + } + if limit > maxLimit { + return maxLimit + } + return limit +} + +// containerLogsFilter scopes a query to the container log records this module ships, so +// logs other sources send to the same environment never leak into an OpenChoreo view. +func (c *Client) containerLogsFilter() string { + return eq(fLogSource, c.logsSource) +} + +func componentLogsConditions(p ComponentLogsParams) string { + conditions := []string{eq(fOCNamespace, p.Namespace)} + if p.ProjectID != "" { + conditions = append(conditions, eq(fOCProjectUID, p.ProjectID)) + } + if p.EnvironmentID != "" { + conditions = append(conditions, eq(fOCEnvironmentUID, p.EnvironmentID)) + } + if p.ComponentID != "" { + conditions = append(conditions, eq(fOCComponentUID, p.ComponentID)) + } + if p.SearchPhrase != "" { + conditions = append(conditions, containsText(fContent, p.SearchPhrase, false)) + } + conditions = append(conditions, levelsCondition(p.LogLevels)) + return and(conditions...) +} + +func workflowLogsConditions(p WorkflowLogsParams) string { + conditions := []string{eq(fNamespaceName, workflowNamespacePrefix+p.Namespace)} + if p.WorkflowRunName != "" { + conditions = append(conditions, eq(fOCWorkflowRun, p.WorkflowRunName)) + } + if p.SearchPhrase != "" { + conditions = append(conditions, containsText(fContent, p.SearchPhrase, false)) + } + conditions = append(conditions, levelsCondition(p.LogLevels)) + return and(conditions...) +} + +// componentLogsFields are the fields a component log page needs; projecting them keeps +// the response small when records carry many labels. +var componentLogsFields = []string{ + fTimestamp, fContent, fNamespaceName, fPodName, fContainerName, + fOCNamespace, fOCProject, fOCProjectUID, fOCComponent, fOCComponentUID, + fOCEnvironment, fOCEnvironmentUID, +} + +func fieldList(names []string) string { + out := "" + for i, n := range names { + if i > 0 { + out += ", " + } + out += field(n) + } + return out +} + +func buildRecordsQuery(scope, conditions, order string, limit int, fields []string) string { + return fetchLogs(). + filter(scope). + filter(conditions). + then("sort " + field(fTimestamp) + " " + sortDirection(order)). + then("limit " + strconv.Itoa(limit)). + then("fields " + fieldList(fields)). + String() +} + +func buildCountQuery(scope, conditions string) string { + return fetchLogs(). + filter(scope). + filter(conditions). + then("summarize total = count()"). + String() +} + +// GenerateComponentLogsQueries returns the page query and the count query for component +// logs. Exported for tests and for debugging a query against a tenant by hand. +func (c *Client) GenerateComponentLogsQueries(p ComponentLogsParams) (records, count string, err error) { + if p.Namespace == "" { + return "", "", fmt.Errorf("namespace is required for component log queries") + } + conditions := componentLogsConditions(p) + scope := c.containerLogsFilter() + return buildRecordsQuery(scope, conditions, p.SortOrder, clampLimit(p.Limit), componentLogsFields), + buildCountQuery(scope, conditions), nil +} + +// GenerateWorkflowLogsQueries returns the page query and the count query for workflow logs. +func (c *Client) GenerateWorkflowLogsQueries(p WorkflowLogsParams) (records, count string, err error) { + if p.Namespace == "" { + return "", "", fmt.Errorf("namespace is required for workflow log queries") + } + conditions := workflowLogsConditions(p) + scope := c.containerLogsFilter() + return buildRecordsQuery(scope, conditions, p.SortOrder, clampLimit(p.Limit), []string{fTimestamp, fContent}), + buildCountQuery(scope, conditions), nil +} + +// runPageAndCount runs a page query and its count query concurrently. They are +// independent reads of the same window, so there is nothing to gain by serialising them. +func (c *Client) runPageAndCount( + ctx context.Context, recordsQuery, countQuery string, start, end time.Time, limit int, +) (*QueryResult, int64, error) { + var page *QueryResult + var total int64 + + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + res, err := c.Query(gctx, QueryRequest{Query: recordsQuery, Start: start, End: end, MaxRecords: limit}) + if err != nil { + return fmt.Errorf("records query failed: %w", err) + } + page = res + return nil + }) + g.Go(func() error { + res, err := c.Query(gctx, QueryRequest{Query: countQuery, Start: start, End: end, MaxRecords: 1}) + if err != nil { + return fmt.Errorf("count query failed: %w", err) + } + total = extractTotal(res) + return nil + }) + if err := g.Wait(); err != nil { + return nil, 0, err + } + return page, total, nil +} + +// extractTotal reads the single "total" value a count query returns. +func extractTotal(res *QueryResult) int64 { + if res == nil || len(res.Records) == 0 { + return 0 + } + n, _ := int64Field(res.Records[0], "total") + return n +} + +// GetComponentLogs returns a page of component logs and the number of matching records. +func (c *Client) GetComponentLogs(ctx context.Context, p ComponentLogsParams) (*ComponentLogsResult, error) { + recordsQuery, countQuery, err := c.GenerateComponentLogsQueries(p) + if err != nil { + return nil, err + } + + page, total, err := c.runPageAndCount(ctx, recordsQuery, countQuery, p.StartTime, p.EndTime, clampLimit(p.Limit)) + if err != nil { + return nil, err + } + + logs := make([]ComponentLogEntry, 0, len(page.Records)) + for _, r := range page.Records { + content := stringField(r, fContent) + logs = append(logs, ComponentLogEntry{ + Timestamp: timeField(r, fTimestamp), + Log: content, + LogLevel: ExtractLogLevel(content), + ComponentUID: stringField(r, fOCComponentUID), + ComponentName: stringField(r, fOCComponent), + EnvironmentUID: stringField(r, fOCEnvironmentUID), + EnvironmentName: stringField(r, fOCEnvironment), + ProjectUID: stringField(r, fOCProjectUID), + ProjectName: stringField(r, fOCProject), + Namespace: stringField(r, fOCNamespace), + PodName: stringField(r, fPodName), + PodNamespace: stringField(r, fNamespaceName), + ContainerName: stringField(r, fContainerName), + }) + } + + return &ComponentLogsResult{Logs: logs, TotalCount: int(total), Took: int(page.Took.Milliseconds())}, nil +} + +// GetWorkflowLogs returns a page of workflow logs and the number of matching records. +func (c *Client) GetWorkflowLogs(ctx context.Context, p WorkflowLogsParams) (*WorkflowLogsResult, error) { + recordsQuery, countQuery, err := c.GenerateWorkflowLogsQueries(p) + if err != nil { + return nil, err + } + + page, total, err := c.runPageAndCount(ctx, recordsQuery, countQuery, p.StartTime, p.EndTime, clampLimit(p.Limit)) + if err != nil { + return nil, err + } + + logs := make([]WorkflowLogEntry, 0, len(page.Records)) + for _, r := range page.Records { + logs = append(logs, WorkflowLogEntry{ + Timestamp: timeField(r, fTimestamp), + Log: stringField(r, fContent), + }) + } + + return &WorkflowLogsResult{Logs: logs, TotalCount: int(total), Took: int(page.Took.Milliseconds())}, nil +} diff --git a/observability-logs-dynatrace/internal/dynatrace/platform.go b/observability-logs-dynatrace/internal/dynatrace/platform.go new file mode 100644 index 00000000..52a4419f --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/platform.go @@ -0,0 +1,252 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "context" + "fmt" + "strconv" + "time" + + "golang.org/x/sync/errgroup" +) + +const ( + // DefaultMaxFilterValues is the number of values returned when the caller does not ask + // for a count. It matches the contract's default. + DefaultMaxFilterValues = 100 + // MaxMaxFilterValues is the ceiling the contract puts on maxValues. + MaxMaxFilterValues = 1000 +) + +// PlatformLogsParams holds parameters for a platform log query. +// +// Every filter is optional except the time range. Multi-value fields OR within themselves +// and AND with each other, and an absent or empty field is not a filter. +type PlatformLogsParams struct { + ClusterInstances []string + Namespaces []string + PodNames []string + ContainerNames []string + Labels map[string]string + StartTime time.Time + EndTime time.Time + SearchPhrase string + LogLevels []string + Limit int + SortOrder string +} + +// PlatformLogEntry is one parsed platform log record. +type PlatformLogEntry struct { + Timestamp time.Time + Log string + LogLevel string + ClusterInstance string + NamespaceName string + PodName string + ContainerName string + PodIP string + NodeName string + ContainerImage string + Labels map[string]string +} + +// PlatformLogsResult is a page of platform logs plus the true total. +type PlatformLogsResult struct { + Logs []PlatformLogEntry + TotalCount int + Took int +} + +// FilterValue is one value a filter takes, with how many records carry it. +type FilterValue struct { + Value string + Count int64 +} + +// FilterValues is the parsed result of a filter-values query. +type FilterValues struct { + Values []FilterValue + TotalValues int64 + Took int +} + +// platformFilterFields maps a filter, named as the request field that accepts it, onto the +// field it selects on. Pod labels are deliberately absent: the set of label keys is open, +// so "which values does this filter take" has no single answer for them. +var platformFilterFields = map[string]string{ + "clusterInstance": fClusterName, + "namespace": fNamespaceName, + "podName": fPodName, + "containerName": fContainerName, +} + +// PlatformFilterField returns the field a filter selects on, and whether the filter is one +// this adapter can list. +func PlatformFilterField(filter string) (string, bool) { + f, ok := platformFilterFields[filter] + return f, ok +} + +// ClearFilterSelections returns params with the named filter's own selections removed, so +// a picker keeps offering the alternatives to what is already selected. +func ClearFilterSelections(p PlatformLogsParams, filter string) PlatformLogsParams { + switch filter { + case "clusterInstance": + p.ClusterInstances = nil + case "namespace": + p.Namespaces = nil + case "podName": + p.PodNames = nil + case "containerName": + p.ContainerNames = nil + } + return p +} + +// platformConditions builds the filter shared by the record, count and filter-values +// queries. Keeping them in one place is what guarantees the values offered for a filter are +// drawn from the records the caller would get back. +func platformConditions(p PlatformLogsParams) string { + conditions := []string{ + anyOf(fClusterName, p.ClusterInstances), + anyOf(fNamespaceName, p.Namespaces), + anyOf(fPodName, p.PodNames), + anyOf(fContainerName, p.ContainerNames), + } + for _, k := range sortedKeys(p.Labels) { + conditions = append(conditions, arrayHasAny(fPodLabels, []string{k + "=" + p.Labels[k]})) + } + if p.SearchPhrase != "" { + conditions = append(conditions, containsText(fContent, p.SearchPhrase, false)) + } + conditions = append(conditions, levelsCondition(p.LogLevels)) + return and(conditions...) +} + +var platformLogsFields = []string{ + fTimestamp, fContent, fClusterName, fNamespaceName, fPodName, fContainerName, + fPodIP, fNodeName, fContainerImage, fPodLabels, +} + +// GeneratePlatformLogsQueries returns the page query and the count query for platform logs. +func (c *Client) GeneratePlatformLogsQueries(p PlatformLogsParams) (records, count string) { + conditions := platformConditions(p) + scope := c.containerLogsFilter() + return buildRecordsQuery(scope, conditions, p.SortOrder, clampLimit(p.Limit), platformLogsFields), + buildCountQuery(scope, conditions) +} + +// GetPlatformLogs queries logs by raw Kubernetes coordinates. +func (c *Client) GetPlatformLogs(ctx context.Context, p PlatformLogsParams) (*PlatformLogsResult, error) { + recordsQuery, countQuery := c.GeneratePlatformLogsQueries(p) + + page, total, err := c.runPageAndCount(ctx, recordsQuery, countQuery, p.StartTime, p.EndTime, clampLimit(p.Limit)) + if err != nil { + return nil, err + } + + logs := make([]PlatformLogEntry, 0, len(page.Records)) + for _, r := range page.Records { + content := stringField(r, fContent) + logs = append(logs, PlatformLogEntry{ + Timestamp: timeField(r, fTimestamp), + Log: content, + LogLevel: ExtractLogLevel(content), + ClusterInstance: stringField(r, fClusterName), + NamespaceName: stringField(r, fNamespaceName), + PodName: stringField(r, fPodName), + ContainerName: stringField(r, fContainerName), + PodIP: stringField(r, fPodIP), + NodeName: stringField(r, fNodeName), + ContainerImage: stringField(r, fContainerImage), + Labels: splitLabels(stringSlice(r, fPodLabels)), + }) + } + + return &PlatformLogsResult{Logs: logs, TotalCount: int(total), Took: int(page.Took.Milliseconds())}, nil +} + +// buildValuesQueries returns the grouped-values query and the distinct-count query for one +// field. Both apply the same filter, so the total describes the list it accompanies. Rows +// where the field is absent or empty are dropped: no filter value would select one. +func buildValuesQueries(fetch func() *pipeline, scope, conditions, valueField, valueSearch string, maxValues int) (values, total string) { + filters := and(conditions, present(valueField)) + if valueSearch != "" { + filters = and(filters, containsText(valueField, valueSearch, false)) + } + + values = fetch(). + filter(scope). + filter(filters). + then("summarize record_count = count(), by: {value = " + field(valueField) + "}"). + // Busiest first, ties broken by value so the order is stable across calls. + then("sort record_count desc, value asc"). + then("limit " + strconv.Itoa(maxValues)). + String() + + total = fetch(). + filter(scope). + filter(filters). + then("summarize total = countDistinctExact(" + field(valueField) + ")"). + String() + return values, total +} + +// GeneratePlatformFilterValuesQueries returns the values query and its total query. +func (c *Client) GeneratePlatformFilterValuesQueries( + p PlatformLogsParams, valueField, valueSearch string, maxValues int, +) (values, total string) { + return buildValuesQueries(fetchLogs, c.containerLogsFilter(), platformConditions(p), valueField, valueSearch, maxValues) +} + +// GetPlatformLogFilterValues lists the distinct values one filter takes under a query. The +// caller is expected to have cleared the listed filter's own selections already. +func (c *Client) GetPlatformLogFilterValues( + ctx context.Context, p PlatformLogsParams, valueField, valueSearch string, maxValues int, +) (*FilterValues, error) { + valuesQuery, totalQuery := c.GeneratePlatformFilterValuesQueries(p, valueField, valueSearch, maxValues) + return c.runValuesQueries(ctx, valuesQuery, totalQuery, p.StartTime, p.EndTime, maxValues) +} + +func (c *Client) runValuesQueries( + ctx context.Context, valuesQuery, totalQuery string, start, end time.Time, maxValues int, +) (*FilterValues, error) { + var valuesRes *QueryResult + var total int64 + + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + res, err := c.Query(gctx, QueryRequest{Query: valuesQuery, Start: start, End: end, MaxRecords: maxValues}) + if err != nil { + return fmt.Errorf("filter values query failed: %w", err) + } + valuesRes = res + return nil + }) + g.Go(func() error { + res, err := c.Query(gctx, QueryRequest{Query: totalQuery, Start: start, End: end, MaxRecords: 1}) + if err != nil { + return fmt.Errorf("filter values total query failed: %w", err) + } + total = extractTotal(res) + return nil + }) + if err := g.Wait(); err != nil { + return nil, err + } + + values := make([]FilterValue, 0, len(valuesRes.Records)) + for _, r := range valuesRes.Records { + v := stringField(r, "value") + if v == "" { + continue + } + n, _ := int64Field(r, "record_count") + values = append(values, FilterValue{Value: v, Count: n}) + } + + return &FilterValues{Values: values, TotalValues: total, Took: int(valuesRes.Took.Milliseconds())}, nil +} diff --git a/observability-logs-dynatrace/internal/dynatrace/values.go b/observability-logs-dynatrace/internal/dynatrace/values.go new file mode 100644 index 00000000..8e0dc23c --- /dev/null +++ b/observability-logs-dynatrace/internal/dynatrace/values.go @@ -0,0 +1,124 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package dynatrace + +import ( + "encoding/json" + "math" + "regexp" + "strconv" + "strings" + "time" +) + +// stringField returns the string at key, or "" when absent or of another type. +func stringField(record map[string]any, key string) string { + if v, ok := record[key].(string); ok { + return v + } + return "" +} + +// stringSlice returns the strings in an array field, skipping non-string entries. +func stringSlice(record map[string]any, key string) []string { + raw, ok := record[key].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} + +// int64Field reads a numeric field. Grail serializes long values as JSON strings, since a +// long does not fit a JSON number losslessly, so both forms are accepted. +func int64Field(record map[string]any, key string) (int64, bool) { + switch v := record[key].(type) { + case float64: + if v != math.Trunc(v) { + return 0, false + } + return int64(v), true + case json.Number: + n, err := v.Int64() + return n, err == nil + case string: + n, err := strconv.ParseInt(v, 10, 64) + if err == nil { + return n, true + } + f, err := strconv.ParseFloat(v, 64) + if err != nil || f != math.Trunc(f) { + return 0, false + } + return int64(f), true + } + return 0, false +} + +// timeField reads a timestamp, which Grail returns as an RFC 3339 string with up to +// nanosecond precision. +func timeField(record map[string]any, key string) time.Time { + s, ok := record[key].(string) + if !ok { + return time.Time{} + } + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return time.Time{} + } + return t.UTC() +} + +// splitLabels turns the "key=value" label array back into a map. The first "=" splits: +// a label key cannot contain one, while a value may. +func splitLabels(entries []string) map[string]string { + if len(entries) == 0 { + return nil + } + labels := make(map[string]string, len(entries)) + for _, e := range entries { + k, v, ok := strings.Cut(e, "=") + if !ok || k == "" { + continue + } + labels[k] = v + } + if len(labels) == 0 { + return nil + } + return labels +} + +// labelKeyPattern is the Kubernetes label key grammar: an optional DNS-subdomain prefix +// followed by "/", then a name of alphanumerics with "-", "_" and "." allowed inside. +var labelKeyPattern = regexp.MustCompile( + `^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?` + + `[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$`) + +// IsValidLabelKey reports whether a key is a well-formed Kubernetes label key. +// +// A label filter is matched as the literal "key=value", so a key holding "=" could shift +// where the value begins and select labels the caller did not name. Keys outside the +// grammar are refused rather than dropped: dropping one would widen the query. +func IsValidLabelKey(key string) bool { + if key == "" || len(key) > 317 { + return false + } + if !labelKeyPattern.MatchString(key) { + return false + } + name := key + if slash := strings.IndexByte(key, '/'); slash >= 0 { + if slash > 253 { + return false + } + name = key[slash+1:] + } + return len(name) <= 63 +} diff --git a/observability-logs-dynatrace/internal/handlers.go b/observability-logs-dynatrace/internal/handlers.go new file mode 100644 index 00000000..9d340b98 --- /dev/null +++ b/observability-logs-dynatrace/internal/handlers.go @@ -0,0 +1,354 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/api/gen" + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/dynatrace" +) + +// LogsBackend is the part of the Dynatrace client the handlers use, so handlers can be +// tested against a fake. +type LogsBackend interface { + GetComponentLogs(ctx context.Context, p dynatrace.ComponentLogsParams) (*dynatrace.ComponentLogsResult, error) + GetWorkflowLogs(ctx context.Context, p dynatrace.WorkflowLogsParams) (*dynatrace.WorkflowLogsResult, error) + GetEvents(ctx context.Context, p dynatrace.EventsQueryParams) (*dynatrace.EventsResult, error) + GetPlatformLogs(ctx context.Context, p dynatrace.PlatformLogsParams) (*dynatrace.PlatformLogsResult, error) + GetPlatformLogFilterValues(ctx context.Context, p dynatrace.PlatformLogsParams, valueField, valueSearch string, maxValues int) (*dynatrace.FilterValues, error) + GetAuditLogs(ctx context.Context, p dynatrace.AuditLogsParams, timelineInterval string) (*dynatrace.AuditLogsResult, error) + GetAuditLogFilterValues(ctx context.Context, p dynatrace.AuditLogsParams, filter, valueSearch string, maxValues int) (*dynatrace.FilterValues, error) +} + +// LogsHandler implements the generated StrictServerInterface. +type LogsHandler struct { + backend LogsBackend + logger *slog.Logger +} + +// NewLogsHandler returns a handler serving from the given backend. +func NewLogsHandler(backend LogsBackend, logger *slog.Logger) *LogsHandler { + return &LogsHandler{backend: backend, logger: logger} +} + +// Ensure LogsHandler implements the interface at compile time. +var _ gen.StrictServerInterface = (*LogsHandler)(nil) + +// Health implements the health check endpoint. +func (h *LogsHandler) Health(_ context.Context, _ gen.HealthRequestObject) (gen.HealthResponseObject, error) { + status := "healthy" + return gen.Health200JSONResponse{Status: &status}, nil +} + +// QueryLogs implements POST /api/v1/logs/query. +func (h *LogsHandler) QueryLogs(ctx context.Context, request gen.QueryLogsRequestObject) (gen.QueryLogsResponseObject, error) { + if request.Body == nil { + return gen.QueryLogs400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("request body is required")}, nil + } + body := request.Body + if !body.EndTime.After(body.StartTime) { + return gen.QueryLogs400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("endTime must be after startTime")}, nil + } + + // A WorkflowSearchScope is identified by having a workflowRunName field. + workflowScope, err := body.SearchScope.AsWorkflowSearchScope() + if err == nil && workflowScope.WorkflowRunName != nil { + if strings.TrimSpace(workflowScope.Namespace) == "" { + return gen.QueryLogs400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("searchScope with a valid namespace is required")}, nil + } + + params := dynatrace.WorkflowLogsParams{ + Namespace: workflowScope.Namespace, + WorkflowRunName: *workflowScope.WorkflowRunName, + StartTime: body.StartTime, + EndTime: body.EndTime, + SearchPhrase: deref(body.SearchPhrase), + LogLevels: logLevels(body.LogLevels), + Limit: deref(body.Limit), + SortOrder: string(deref(body.SortOrder)), + } + result, err := h.backend.GetWorkflowLogs(ctx, params) + if err != nil { + h.logger.Error("Failed to query workflow logs", + slog.String("namespace", workflowScope.Namespace), slog.Any("error", err)) + return gen.QueryLogs500JSONResponse{Title: ptr(gen.InternalServerError), Message: ptr("internal server error")}, nil + } + return gen.QueryLogs200JSONResponse(toWorkflowLogsResponse(result)), nil + } + + scope, err := body.SearchScope.AsComponentSearchScope() + if err != nil || strings.TrimSpace(scope.Namespace) == "" { + return gen.QueryLogs400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("searchScope with a valid namespace is required")}, nil + } + + params := dynatrace.ComponentLogsParams{ + Namespace: scope.Namespace, + ProjectID: deref(scope.ProjectUid), + EnvironmentID: deref(scope.EnvironmentUid), + ComponentID: deref(scope.ComponentUid), + StartTime: body.StartTime, + EndTime: body.EndTime, + SearchPhrase: deref(body.SearchPhrase), + LogLevels: logLevels(body.LogLevels), + Limit: deref(body.Limit), + SortOrder: string(deref(body.SortOrder)), + } + result, err := h.backend.GetComponentLogs(ctx, params) + if err != nil { + h.logger.Error("Failed to query component logs", + slog.String("namespace", scope.Namespace), slog.Any("error", err)) + return gen.QueryLogs500JSONResponse{Title: ptr(gen.InternalServerError), Message: ptr("internal server error")}, nil + } + return gen.QueryLogs200JSONResponse(toComponentLogsResponse(result)), nil +} + +// QueryEvents implements POST /api/v1/events/query. +func (h *LogsHandler) QueryEvents(ctx context.Context, request gen.QueryEventsRequestObject) (gen.QueryEventsResponseObject, error) { + if request.Body == nil { + return gen.QueryEvents400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("request body is required")}, nil + } + body := request.Body + if !body.EndTime.After(body.StartTime) { + return gen.QueryEvents400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("endTime must be after startTime")}, nil + } + + params := dynatrace.EventsQueryParams{ + Reasons: deref(body.Reasons), + StartTime: body.StartTime, + EndTime: body.EndTime, + Limit: deref(body.Limit), + SortOrder: string(deref(body.SortOrder)), + } + + if body.SearchScope == nil { + // Unscoped sweeps are legal only when narrowed by reasons; a missing scope on its + // own must never widen to every namespace. + if len(params.Reasons) == 0 { + return gen.QueryEvents400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("searchScope is required unless reasons is set for an unscoped sweep"), + }, nil + } + } else if workflowScope, err := body.SearchScope.AsWorkflowSearchScope(); err == nil && workflowScope.WorkflowRunName != nil { + if strings.TrimSpace(workflowScope.Namespace) == "" || strings.TrimSpace(*workflowScope.WorkflowRunName) == "" { + return gen.QueryEvents400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("searchScope with a valid namespace and workflowRunName is required"), + }, nil + } + params.Workflow = &dynatrace.WorkflowEventsScope{ + Namespace: workflowScope.Namespace, + WorkflowRunName: *workflowScope.WorkflowRunName, + TaskName: deref(workflowScope.TaskName), + } + } else { + scope, err := body.SearchScope.AsComponentSearchScope() + if err != nil || strings.TrimSpace(scope.Namespace) == "" { + return gen.QueryEvents400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("searchScope with a valid namespace is required")}, nil + } + params.Component = &dynatrace.ComponentEventsScope{ + Namespace: scope.Namespace, + ProjectID: deref(scope.ProjectUid), + ComponentID: deref(scope.ComponentUid), + EnvironmentID: deref(scope.EnvironmentUid), + } + } + + result, err := h.backend.GetEvents(ctx, params) + if err != nil { + h.logger.Error("Failed to query events", slog.Any("error", err)) + return gen.QueryEvents500JSONResponse{Title: ptr(gen.InternalServerError), Message: ptr("internal server error")}, nil + } + return gen.QueryEvents200JSONResponse(toEventsResponse(result)), nil +} + +// Alerting is not implemented by this adapter. Dynatrace alerting is configured with +// anomaly detectors and workflows in the tenant rather than per-rule over this API, so +// the alert endpoints answer 501 instead of pretending to have synced a rule. +const alertingNotImplemented = "alert rules are not supported by the Dynatrace logs adapter; configure alerting with Dynatrace anomaly detectors" + +// notImplemented writes a 501 for operations whose generated response set has no 501. +type notImplemented struct{} + +func (notImplemented) write(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + return json.NewEncoder(w).Encode(gen.ErrorResponse{ + Title: ptr(gen.NotImplemented), + Message: ptr(alertingNotImplemented), + }) +} + +func (n notImplemented) VisitCreateAlertRuleResponse(w http.ResponseWriter) error { return n.write(w) } +func (n notImplemented) VisitGetAlertRuleResponse(w http.ResponseWriter) error { return n.write(w) } +func (n notImplemented) VisitUpdateAlertRuleResponse(w http.ResponseWriter) error { return n.write(w) } +func (n notImplemented) VisitDeleteAlertRuleResponse(w http.ResponseWriter) error { return n.write(w) } +func (n notImplemented) VisitHandleAlertWebhookResponse(w http.ResponseWriter) error { + return n.write(w) +} + +// CreateAlertRule implements POST /api/v1alpha1/alerts/rules. +func (h *LogsHandler) CreateAlertRule(context.Context, gen.CreateAlertRuleRequestObject) (gen.CreateAlertRuleResponseObject, error) { + return notImplemented{}, nil +} + +// GetAlertRule implements GET /api/v1alpha1/alerts/rules/{ruleName}. +func (h *LogsHandler) GetAlertRule(context.Context, gen.GetAlertRuleRequestObject) (gen.GetAlertRuleResponseObject, error) { + return notImplemented{}, nil +} + +// UpdateAlertRule implements PUT /api/v1alpha1/alerts/rules/{ruleName}. +func (h *LogsHandler) UpdateAlertRule(context.Context, gen.UpdateAlertRuleRequestObject) (gen.UpdateAlertRuleResponseObject, error) { + return notImplemented{}, nil +} + +// DeleteAlertRule implements DELETE /api/v1alpha1/alerts/rules/{ruleName}. +func (h *LogsHandler) DeleteAlertRule(context.Context, gen.DeleteAlertRuleRequestObject) (gen.DeleteAlertRuleResponseObject, error) { + return notImplemented{}, nil +} + +// HandleAlertWebhook implements POST /api/v1alpha1/alerts/webhook. +func (h *LogsHandler) HandleAlertWebhook(context.Context, gen.HandleAlertWebhookRequestObject) (gen.HandleAlertWebhookResponseObject, error) { + return notImplemented{}, nil +} + +func toComponentLogsResponse(result *dynatrace.ComponentLogsResult) gen.LogsQueryResponse { + entries := make([]gen.ComponentLogEntry, 0, len(result.Logs)) + for i := range result.Logs { + l := &result.Logs[i] + entry := gen.ComponentLogEntry{ + Timestamp: &l.Timestamp, + Log: &l.Log, + Level: strPtr(l.LogLevel), + Metadata: &struct { + ComponentName *string `json:"componentName,omitempty"` + ComponentUid *openapi_types.UUID `json:"componentUid,omitempty"` + ContainerName *string `json:"containerName,omitempty"` + EnvironmentName *string `json:"environmentName,omitempty"` + EnvironmentUid *openapi_types.UUID `json:"environmentUid,omitempty"` + NamespaceName *string `json:"namespaceName,omitempty"` + PodName *string `json:"podName,omitempty"` + PodNamespace *string `json:"podNamespace,omitempty"` + ProjectName *string `json:"projectName,omitempty"` + ProjectUid *openapi_types.UUID `json:"projectUid,omitempty"` + }{ + ComponentName: strPtr(l.ComponentName), + ComponentUid: uuidPtr(l.ComponentUID), + ContainerName: strPtr(l.ContainerName), + EnvironmentName: strPtr(l.EnvironmentName), + EnvironmentUid: uuidPtr(l.EnvironmentUID), + NamespaceName: strPtr(l.Namespace), + PodName: strPtr(l.PodName), + PodNamespace: strPtr(l.PodNamespace), + ProjectName: strPtr(l.ProjectName), + ProjectUid: uuidPtr(l.ProjectUID), + }, + } + entries = append(entries, entry) + } + + logs := gen.LogsQueryResponse_Logs{} + _ = logs.FromLogsQueryResponseLogs0(entries) + return gen.LogsQueryResponse{Logs: &logs, Total: &result.TotalCount, TookMs: &result.Took} +} + +func toWorkflowLogsResponse(result *dynatrace.WorkflowLogsResult) gen.LogsQueryResponse { + entries := make([]gen.WorkflowLogEntry, 0, len(result.Logs)) + for i := range result.Logs { + l := &result.Logs[i] + entries = append(entries, gen.WorkflowLogEntry{Timestamp: &l.Timestamp, Log: &l.Log}) + } + + logs := gen.LogsQueryResponse_Logs{} + _ = logs.FromLogsQueryResponseLogs1(entries) + return gen.LogsQueryResponse{Logs: &logs, Total: &result.TotalCount, TookMs: &result.Took} +} + +func toEventsResponse(result *dynatrace.EventsResult) gen.EventsQueryResponse { + entries := make([]gen.EventEntry, 0, len(result.Events)) + for i := range result.Events { + e := &result.Events[i] + entries = append(entries, gen.EventEntry{ + Timestamp: &e.Timestamp, + Message: strPtr(e.Message), + Type: strPtr(e.Type), + Reason: strPtr(e.Reason), + Metadata: &struct { + ComponentName *string `json:"componentName,omitempty"` + ComponentUid *openapi_types.UUID `json:"componentUid,omitempty"` + EnvironmentName *string `json:"environmentName,omitempty"` + EnvironmentUid *openapi_types.UUID `json:"environmentUid,omitempty"` + NamespaceName *string `json:"namespaceName,omitempty"` + ObjectKind *string `json:"objectKind,omitempty"` + ObjectName *string `json:"objectName,omitempty"` + ObjectNamespace *string `json:"objectNamespace,omitempty"` + ProjectName *string `json:"projectName,omitempty"` + ProjectUid *openapi_types.UUID `json:"projectUid,omitempty"` + }{ + ComponentName: strPtr(e.ComponentName), + ComponentUid: uuidPtr(e.ComponentID), + EnvironmentName: strPtr(e.EnvironmentName), + EnvironmentUid: uuidPtr(e.EnvironmentID), + NamespaceName: strPtr(e.NamespaceName), + ObjectKind: strPtr(e.ObjectKind), + ObjectName: strPtr(e.ObjectName), + ObjectNamespace: strPtr(e.ObjectNamespace), + ProjectName: strPtr(e.ProjectName), + ProjectUid: uuidPtr(e.ProjectID), + }, + }) + } + return gen.EventsQueryResponse{Events: &entries, Total: result.TotalCount, TookMs: &result.Took} +} + +// logLevels converts the generated level enum slice to strings. +func logLevels[T ~string](levels *[]T) []string { + if levels == nil { + return nil + } + out := make([]string, len(*levels)) + for i, l := range *levels { + out[i] = string(l) + } + return out +} + +func uuidPtr(s string) *openapi_types.UUID { + if s == "" { + return nil + } + parsed, err := uuid.Parse(s) + if err != nil { + return nil + } + u := openapi_types.UUID(parsed) + return &u +} + +func deref[T any](p *T) T { + var zero T + if p == nil { + return zero + } + return *p +} + +func ptr[T any](v T) *T { + return &v +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/observability-logs-dynatrace/internal/handlers_test.go b/observability-logs-dynatrace/internal/handlers_test.go new file mode 100644 index 00000000..b074e8dd --- /dev/null +++ b/observability-logs-dynatrace/internal/handlers_test.go @@ -0,0 +1,334 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/dynatrace" +) + +// fakeBackend records the parameters each call received and answers with canned results. +type fakeBackend struct { + err error + + componentParams dynatrace.ComponentLogsParams + workflowParams dynatrace.WorkflowLogsParams + eventsParams dynatrace.EventsQueryParams + platformParams dynatrace.PlatformLogsParams + auditParams dynatrace.AuditLogsParams + valueField string + auditFilter string + maxValues int + timeline string +} + +func (f *fakeBackend) GetComponentLogs(_ context.Context, p dynatrace.ComponentLogsParams) (*dynatrace.ComponentLogsResult, error) { + f.componentParams = p + if f.err != nil { + return nil, f.err + } + return &dynatrace.ComponentLogsResult{ + Logs: []dynatrace.ComponentLogEntry{{ + Timestamp: time.Unix(1, 0).UTC(), Log: "ERROR boom", LogLevel: "ERROR", + ComponentUID: "8d7b5a4e-3f33-4c5e-9d1a-2b3c4d5e6f70", ComponentName: "api", ProjectUID: "not-a-uuid", + }}, + TotalCount: 1, Took: 3, + }, nil +} + +func (f *fakeBackend) GetWorkflowLogs(_ context.Context, p dynatrace.WorkflowLogsParams) (*dynatrace.WorkflowLogsResult, error) { + f.workflowParams = p + if f.err != nil { + return nil, f.err + } + return &dynatrace.WorkflowLogsResult{Logs: []dynatrace.WorkflowLogEntry{{Timestamp: time.Unix(1, 0).UTC(), Log: "step"}}, TotalCount: 1}, nil +} + +func (f *fakeBackend) GetEvents(_ context.Context, p dynatrace.EventsQueryParams) (*dynatrace.EventsResult, error) { + f.eventsParams = p + if f.err != nil { + return nil, f.err + } + return &dynatrace.EventsResult{Events: []dynatrace.EventEntry{{Timestamp: time.Unix(1, 0).UTC(), Reason: "Started", Type: "Normal"}}, TotalCount: 1}, nil +} + +func (f *fakeBackend) GetPlatformLogs(_ context.Context, p dynatrace.PlatformLogsParams) (*dynatrace.PlatformLogsResult, error) { + f.platformParams = p + if f.err != nil { + return nil, f.err + } + return &dynatrace.PlatformLogsResult{Logs: []dynatrace.PlatformLogEntry{{ + Timestamp: time.Unix(1, 0).UTC(), Log: "hi", LogLevel: "INFO", Labels: map[string]string{"app": "x"}, + }}, TotalCount: 1}, nil +} + +func (f *fakeBackend) GetPlatformLogFilterValues(_ context.Context, p dynatrace.PlatformLogsParams, valueField, _ string, maxValues int) (*dynatrace.FilterValues, error) { + f.platformParams, f.valueField, f.maxValues = p, valueField, maxValues + if f.err != nil { + return nil, f.err + } + return &dynatrace.FilterValues{Values: []dynatrace.FilterValue{{Value: "ns", Count: 2}}, TotalValues: 1}, nil +} + +func (f *fakeBackend) GetAuditLogs(_ context.Context, p dynatrace.AuditLogsParams, timeline string) (*dynatrace.AuditLogsResult, error) { + f.auditParams, f.timeline = p, timeline + if f.err != nil { + return nil, f.err + } + return &dynatrace.AuditLogsResult{ + Records: []dynatrace.AuditRecord{{ + SchemaVersion: "1.0", EventID: "e1", EventTime: time.Unix(1, 0).UTC(), + Actor: dynatrace.AuditActor{Type: "user", ID: "alice", Entitlements: map[string][]string{"groups": {"a"}}}, + Result: "success", + HTTP: &dynatrace.AuditHTTPInfo{Method: "GET"}, + Resource: &dynatrace.AuditResource{Name: "shop", Metadata: map[string]any{"k": "v"}}, + Metadata: map[string]any{"x": 1}, + Collector: dynatrace.AuditCollectorInfo{PodName: "api-0"}, + }}, + Total: 1, + Timeline: &dynatrace.AuditTimeline{Interval: "1h", Buckets: []dynatrace.AuditTimelineBucket{ + {StartTime: time.Unix(0, 0).UTC(), Total: 1, Counts: map[string]int64{"success": 1}}, + }}, + }, nil +} + +func (f *fakeBackend) GetAuditLogFilterValues(_ context.Context, p dynatrace.AuditLogsParams, filter, _ string, maxValues int) (*dynatrace.FilterValues, error) { + f.auditParams, f.auditFilter, f.maxValues = p, filter, maxValues + if f.err != nil { + return nil, f.err + } + return &dynatrace.FilterValues{Values: []dynatrace.FilterValue{{Value: "alice", Count: 1}}, TotalValues: 1}, nil +} + +func newTestServer(t *testing.T, backend *fakeBackend) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewServer("0", NewLogsHandler(backend, logger), logger).Handler() +} + +func do(t *testing.T, h http.Handler, method, path, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out +} + +const window = `"startTime":"2026-01-01T00:00:00Z","endTime":"2026-01-01T01:00:00Z"` + +func TestHealth(t *testing.T) { + code, body := do(t, newTestServer(t, &fakeBackend{}), http.MethodGet, "/health", "") + if code != http.StatusOK || body["status"] != "healthy" { + t.Errorf("health: %d %v", code, body) + } +} + +func TestQueryComponentLogs(t *testing.T) { + b := &fakeBackend{} + code, body := do(t, newTestServer(t, b), http.MethodPost, "/api/v1/logs/query", + `{`+window+`,"limit":20,"sortOrder":"asc","logLevels":["ERROR"],"searchPhrase":"boom",`+ + `"searchScope":{"namespace":"default","projectUid":"p","environmentUid":"e","componentUid":"c"}}`) + if code != http.StatusOK { + t.Fatalf("status %d: %v", code, body) + } + p := b.componentParams + if p.Namespace != "default" || p.ProjectID != "p" || p.EnvironmentID != "e" || p.ComponentID != "c" || + p.Limit != 20 || p.SortOrder != "asc" || p.SearchPhrase != "boom" || len(p.LogLevels) != 1 { + t.Errorf("unexpected params %+v", p) + } + logs := body["logs"].([]any) + entry := logs[0].(map[string]any) + meta := entry["metadata"].(map[string]any) + if entry["level"] != "ERROR" || meta["componentUid"] == nil || meta["projectUid"] != nil { + t.Errorf("unexpected entry %v", entry) + } +} + +func TestQueryWorkflowLogs(t *testing.T) { + b := &fakeBackend{} + code, body := do(t, newTestServer(t, b), http.MethodPost, "/api/v1/logs/query", + `{`+window+`,"searchScope":{"namespace":"default","workflowRunName":"run-1"}}`) + if code != http.StatusOK || b.workflowParams.WorkflowRunName != "run-1" { + t.Fatalf("status %d: %v %+v", code, body, b.workflowParams) + } + if len(body["logs"].([]any)) != 1 { + t.Errorf("unexpected body %v", body) + } +} + +func TestQueryLogsBadRequests(t *testing.T) { + h := newTestServer(t, &fakeBackend{}) + for name, body := range map[string]string{ + "no namespace": `{` + window + `,"searchScope":{"namespace":""}}`, + "workflow no namespace": `{` + window + `,"searchScope":{"namespace":" ","workflowRunName":"r"}}`, + "inverted window": `{"startTime":"2026-01-01T01:00:00Z","endTime":"2026-01-01T00:00:00Z","searchScope":{"namespace":"d"}}`, + } { + if code, _ := do(t, h, http.MethodPost, "/api/v1/logs/query", body); code != http.StatusBadRequest { + t.Errorf("%s: status %d", name, code) + } + } +} + +func TestQueryLogsBackendError(t *testing.T) { + h := newTestServer(t, &fakeBackend{err: errors.New("boom")}) + for _, body := range []string{ + `{` + window + `,"searchScope":{"namespace":"d"}}`, + `{` + window + `,"searchScope":{"namespace":"d","workflowRunName":"r"}}`, + } { + code, resp := do(t, h, http.MethodPost, "/api/v1/logs/query", body) + if code != http.StatusInternalServerError || strings.Contains(resp["message"].(string), "boom") { + t.Errorf("status %d body %v", code, resp) + } + } +} + +func TestQueryEventsScopes(t *testing.T) { + b := &fakeBackend{} + h := newTestServer(t, b) + + code, body := do(t, h, http.MethodPost, "/api/v1/events/query", `{`+window+`,"searchScope":{"namespace":"d","componentUid":"c"}}`) + if code != http.StatusOK || b.eventsParams.Component == nil || b.eventsParams.Component.ComponentID != "c" { + t.Fatalf("component scope: %d %v %+v", code, body, b.eventsParams) + } + if body["total"].(float64) != 1 { + t.Errorf("unexpected body %v", body) + } + + code, _ = do(t, h, http.MethodPost, "/api/v1/events/query", `{`+window+`,"searchScope":{"namespace":"d","workflowRunName":"r","taskName":"t"}}`) + if code != http.StatusOK || b.eventsParams.Workflow == nil || b.eventsParams.Workflow.TaskName != "t" { + t.Fatalf("workflow scope: %d %+v", code, b.eventsParams) + } + + code, _ = do(t, h, http.MethodPost, "/api/v1/events/query", `{`+window+`,"reasons":["DeploymentSucceeded"],"sortOrder":"asc"}`) + if code != http.StatusOK || b.eventsParams.Component != nil || b.eventsParams.Workflow != nil || len(b.eventsParams.Reasons) != 1 { + t.Fatalf("unscoped sweep: %d %+v", code, b.eventsParams) + } +} + +func TestQueryEventsBadRequests(t *testing.T) { + h := newTestServer(t, &fakeBackend{}) + for name, body := range map[string]string{ + "unscoped without reasons": `{` + window + `}`, + "no namespace": `{` + window + `,"searchScope":{"namespace":""}}`, + "workflow no run": `{` + window + `,"searchScope":{"namespace":"d","workflowRunName":""}}`, + "inverted window": `{"startTime":"2026-01-01T01:00:00Z","endTime":"2026-01-01T00:00:00Z","reasons":["x"]}`, + } { + if code, _ := do(t, h, http.MethodPost, "/api/v1/events/query", body); code != http.StatusBadRequest { + t.Errorf("%s: status %d", name, code) + } + } + if code, _ := do(t, newTestServer(t, &fakeBackend{err: errors.New("x")}), http.MethodPost, "/api/v1/events/query", + `{`+window+`,"reasons":["x"]}`); code != http.StatusInternalServerError { + t.Errorf("backend error: status %d", code) + } +} + +func TestAlertEndpointsNotImplemented(t *testing.T) { + h := newTestServer(t, &fakeBackend{}) + rule := `{"metadata":{"name":"r","namespace":"n","projectUid":"8d7b5a4e-3f33-4c5e-9d1a-2b3c4d5e6f70",` + + `"environmentUid":"8d7b5a4e-3f33-4c5e-9d1a-2b3c4d5e6f70","componentUid":"8d7b5a4e-3f33-4c5e-9d1a-2b3c4d5e6f70"},` + + `"source":{"query":"x"},"condition":{"enabled":true,"window":"5m","interval":"1m","operator":"gt","threshold":1}}` + for _, c := range []struct{ method, path, body string }{ + {http.MethodPost, "/api/v1alpha1/alerts/rules", rule}, + {http.MethodGet, "/api/v1alpha1/alerts/rules/r", ""}, + {http.MethodPut, "/api/v1alpha1/alerts/rules/r", rule}, + {http.MethodDelete, "/api/v1alpha1/alerts/rules/r", ""}, + {http.MethodPost, "/api/v1alpha1/alerts/webhook", `{}`}, + } { + code, body := do(t, h, c.method, c.path, c.body) + if code != http.StatusNotImplemented || body["title"] != "notImplemented" { + t.Errorf("%s %s: %d %v", c.method, c.path, code, body) + } + } +} + +func TestQueryPlatformLogs(t *testing.T) { + b := &fakeBackend{} + h := newTestServer(t, b) + code, body := do(t, h, http.MethodPost, "/api/v1alpha1/platform-logs/query", + `{`+window+`,"namespace":["a"],"labels":{"app":"x"},"logLevels":["WARN"]}`) + if code != http.StatusOK || b.platformParams.Labels["app"] != "x" || b.platformParams.Namespaces[0] != "a" { + t.Fatalf("status %d: %v %+v", code, body, b.platformParams) + } + if code, _ := do(t, h, http.MethodPost, "/api/v1alpha1/platform-logs/query", `{`+window+`,"labels":{"a=b":"x"}}`); code != http.StatusBadRequest { + t.Errorf("invalid label key: status %d", code) + } +} + +func TestQueryPlatformLogFilterValues(t *testing.T) { + b := &fakeBackend{} + h := newTestServer(t, b) + code, body := do(t, h, http.MethodPost, "/api/v1alpha1/platform-logs/filter-values", + `{"query":{`+window+`,"namespace":["a"],"podName":["p"]},"filter":"namespace","maxValues":5000}`) + if code != http.StatusOK { + t.Fatalf("status %d: %v", code, body) + } + if b.platformParams.Namespaces != nil || len(b.platformParams.PodNames) != 1 { + t.Errorf("own filter selections should be cleared: %+v", b.platformParams) + } + if b.valueField != "k8s.namespace.name" || b.maxValues != dynatrace.MaxMaxFilterValues { + t.Errorf("field %q maxValues %d", b.valueField, b.maxValues) + } + if body["filter"] != "namespace" { + t.Errorf("filter not echoed: %v", body) + } +} + +func TestQueryAuditLogs(t *testing.T) { + b := &fakeBackend{} + h := newTestServer(t, b) + code, body := do(t, h, http.MethodPost, "/api/v1alpha1/audit-logs/query", + `{`+window+`,"includeTimeline":true,"timelineInterval":"15m","actor":{"id":["alice"]},"result":["denied"],"limit":5000}`) + if code != http.StatusOK { + t.Fatalf("status %d: %v", code, body) + } + if b.timeline != "15m" || b.auditParams.ActorIDs[0] != "alice" || b.auditParams.Limit != 1000 { + t.Errorf("unexpected params %+v timeline %q", b.auditParams, b.timeline) + } + record := body["records"].([]any)[0].(map[string]any) + if record["collector"] == nil || record["http"] == nil || body["timeline"] == nil { + t.Errorf("unexpected record %v", record) + } + + for name, req := range map[string]string{ + "bad category": `{` + window + `,"category":["nope"]}`, + "bad interval": `{` + window + `,"includeTimeline":true,"timelineInterval":"5s"}`, + "inverted window": `{"startTime":"2026-01-01T01:00:00Z","endTime":"2026-01-01T00:00:00Z"}`, + } { + if code, _ := do(t, h, http.MethodPost, "/api/v1alpha1/audit-logs/query", req); code != http.StatusBadRequest { + t.Errorf("%s: status %d", name, code) + } + } +} + +func TestQueryAuditLogFilterValues(t *testing.T) { + b := &fakeBackend{} + h := newTestServer(t, b) + code, body := do(t, h, http.MethodPost, "/api/v1alpha1/audit-logs/filter-values", + `{"query":{`+window+`,"actor":{"id":["alice"],"type":["user"]}},"filter":"actor.id"}`) + if code != http.StatusOK { + t.Fatalf("status %d: %v", code, body) + } + if b.auditParams.ActorIDs != nil || len(b.auditParams.ActorTypes) != 1 || b.auditFilter != "actor.id" { + t.Errorf("unexpected params %+v", b.auditParams) + } + if code, _ := do(t, newTestServer(t, &fakeBackend{err: errors.New("x")}), http.MethodPost, "/api/v1alpha1/audit-logs/filter-values", + `{"query":{`+window+`},"filter":"action"}`); code != http.StatusInternalServerError { + t.Errorf("backend error: status %d", code) + } +} diff --git a/observability-logs-dynatrace/internal/platform_handlers.go b/observability-logs-dynatrace/internal/platform_handlers.go new file mode 100644 index 00000000..0f2e2390 --- /dev/null +++ b/observability-logs-dynatrace/internal/platform_handlers.go @@ -0,0 +1,220 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "fmt" + "log/slog" + "sort" + + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/api/gen" + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/dynatrace" +) + +// QueryPlatformLogs implements POST /api/v1alpha1/platform-logs/query. +func (h *LogsHandler) QueryPlatformLogs( + ctx context.Context, request gen.QueryPlatformLogsRequestObject, +) (gen.QueryPlatformLogsResponseObject, error) { + if request.Body == nil { + return gen.QueryPlatformLogs400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("request body is required"), + }, nil + } + + if key, ok := firstInvalidLabelKey(request.Body); !ok { + return gen.QueryPlatformLogs400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr(fmt.Sprintf("label key %q is not a valid Kubernetes label key", key)), + }, nil + } + + result, err := h.backend.GetPlatformLogs(ctx, platformQueryParams(request.Body)) + if err != nil { + h.logger.Error("Failed to query platform logs", + slog.String("function", "QueryPlatformLogs"), + slog.Any("error", err), + ) + return gen.QueryPlatformLogs500JSONResponse{ + Title: ptr(gen.InternalServerError), + Message: ptr("internal server error"), + }, nil + } + + logs := make([]gen.PlatformLog, 0, len(result.Logs)) + for _, entry := range result.Logs { + log := gen.PlatformLog{ + Timestamp: entry.Timestamp, + Log: entry.Log, + Level: strPtr(entry.LogLevel), + ClusterInstance: strPtr(entry.ClusterInstance), + NamespaceName: strPtr(entry.NamespaceName), + PodName: strPtr(entry.PodName), + ContainerName: strPtr(entry.ContainerName), + PodIp: strPtr(entry.PodIP), + NodeName: strPtr(entry.NodeName), + ContainerImage: strPtr(entry.ContainerImage), + } + if len(entry.Labels) > 0 { + labels := entry.Labels + log.Labels = &labels + } + logs = append(logs, log) + } + + return gen.QueryPlatformLogs200JSONResponse{ + Logs: logs, + Total: result.TotalCount, + TookMs: result.Took, + }, nil +} + +// QueryPlatformLogFilterValues implements POST /api/v1alpha1/platform-logs/filter-values. +// +// Answers "what can I pick next" for one filter: the distinct values it takes among the +// records the rest of the query already selects. The listed filter's own selections are +// dropped first - counting them would offer back only what is already picked, so a choice +// could never be widened once made. +func (h *LogsHandler) QueryPlatformLogFilterValues( + ctx context.Context, request gen.QueryPlatformLogFilterValuesRequestObject, +) (gen.QueryPlatformLogFilterValuesResponseObject, error) { + if request.Body == nil { + return gen.QueryPlatformLogFilterValues400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("request body is required"), + }, nil + } + body := request.Body + + if key, ok := firstInvalidLabelKey(&body.Query); !ok { + return gen.QueryPlatformLogFilterValues400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr(fmt.Sprintf("label key %q is not a valid Kubernetes label key", key)), + }, nil + } + + filter := string(body.Filter) + valueField, ok := dynatrace.PlatformFilterField(filter) + if !ok { + return gen.QueryPlatformLogFilterValues400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr(fmt.Sprintf("filter %q cannot be listed", filter)), + }, nil + } + + maxValues := dynatrace.DefaultMaxFilterValues + if body.MaxValues != nil { + maxValues = *body.MaxValues + } + if maxValues < 1 { + maxValues = 1 + } + if maxValues > dynatrace.MaxMaxFilterValues { + maxValues = dynatrace.MaxMaxFilterValues + } + + valueSearch := "" + if body.ValueSearch != nil { + valueSearch = *body.ValueSearch + } + + // limit and sortOrder page and order records; this returns none, so they are ignored + // rather than rejected, as the contract requires. + params := dynatrace.ClearFilterSelections(platformQueryParams(&body.Query), filter) + + result, err := h.backend.GetPlatformLogFilterValues(ctx, params, valueField, valueSearch, maxValues) + if err != nil { + h.logger.Error("Failed to query platform log filter values", + slog.String("function", "QueryPlatformLogFilterValues"), + slog.String("filter", filter), + slog.Any("error", err), + ) + return gen.QueryPlatformLogFilterValues500JSONResponse{ + Title: ptr(gen.InternalServerError), + Message: ptr("internal server error"), + }, nil + } + + values := make([]gen.PlatformLogFilterValue, 0, len(result.Values)) + for _, v := range result.Values { + values = append(values, gen.PlatformLogFilterValue{Value: v.Value, Count: v.Count}) + } + + return gen.QueryPlatformLogFilterValues200JSONResponse{ + Filter: filter, + Values: values, + // The contract treats this as a sense of scale rather than a guaranteed total; + // countDistinctExact makes it the exact count. + TotalValues: result.TotalValues, + TookMs: result.Took, + }, nil +} + +// platformQueryParams maps a platform logs query request onto the client's parameters. Both +// the record query and the filter-values query take the same request shape, so the mapping +// lives here rather than in each handler. +func platformQueryParams(body *gen.PlatformLogsQueryRequest) dynatrace.PlatformLogsParams { + params := dynatrace.PlatformLogsParams{ + StartTime: body.StartTime, + EndTime: body.EndTime, + ClusterInstances: derefSlice(body.ClusterInstance), + Namespaces: derefSlice(body.Namespace), + PodNames: derefSlice(body.PodName), + ContainerNames: derefSlice(body.ContainerName), + } + if body.Labels != nil { + params.Labels = *body.Labels + } + if body.Limit != nil { + params.Limit = *body.Limit + } + if body.SortOrder != nil { + params.SortOrder = string(*body.SortOrder) + } + if body.SearchPhrase != nil { + params.SearchPhrase = *body.SearchPhrase + } + if body.LogLevels != nil { + levels := make([]string, len(*body.LogLevels)) + for i, l := range *body.LogLevels { + levels[i] = string(l) + } + params.LogLevels = levels + } + return params +} + +// derefSlice returns the pointed-to slice, or nil. An absent multi-value filter and an +// empty one mean the same thing here: not a filter. +func derefSlice(v *[]string) []string { + if v == nil { + return nil + } + return *v +} + +// firstInvalidLabelKey reports the first label key that is not a well-formed Kubernetes +// label key, if any. +// +// Labels are matched as the literal "key=value", so a key holding "=" would shift where the +// value begins. A malformed key is refused here rather than silently dropped: dropping it +// would widen the query, returning records the caller did not ask for. +func firstInvalidLabelKey(body *gen.PlatformLogsQueryRequest) (string, bool) { + if body.Labels == nil { + return "", true + } + // Sorted so the key reported is the same one on every call for a given request. + keys := make([]string, 0, len(*body.Labels)) + for k := range *body.Labels { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if !dynatrace.IsValidLabelKey(k) { + return k, false + } + } + return "", true +} diff --git a/observability-logs-dynatrace/internal/server.go b/observability-logs-dynatrace/internal/server.go new file mode 100644 index 00000000..bd72da5c --- /dev/null +++ b/observability-logs-dynatrace/internal/server.go @@ -0,0 +1,61 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/api/gen" +) + +// Server serves the logging adapter API. +type Server struct { + port string + httpServer *http.Server + logger *slog.Logger +} + +// NewServer wires the handler into the generated router. +func NewServer(port string, logsHandler *LogsHandler, logger *slog.Logger) *Server { + strictHandler := gen.NewStrictHandler(logsHandler, nil) + + mux := http.NewServeMux() + handler := gen.HandlerFromMux(strictHandler, mux) + + httpServer := &http.Server{ + Addr: ":" + port, + Handler: handler, + ReadTimeout: 15 * time.Second, + // Longer than the other adapters: a Grail query over a wide window can take a + // while, and the page, count and timeline queries all have to finish. + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return &Server{port: port, httpServer: httpServer, logger: logger} +} + +// Handler exposes the router, for tests. +func (s *Server) Handler() http.Handler { + return s.httpServer.Handler +} + +// Start serves until Shutdown is called. +func (s *Server) Start() error { + s.logger.Info("Starting server", slog.String("port", s.port)) + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("failed to start server: %w", err) + } + return nil +} + +// Shutdown stops the server gracefully. +func (s *Server) Shutdown(ctx context.Context) error { + s.logger.Info("Shutting down server") + return s.httpServer.Shutdown(ctx) +} diff --git a/observability-logs-dynatrace/main.go b/observability-logs-dynatrace/main.go new file mode 100644 index 00000000..b89c9102 --- /dev/null +++ b/observability-logs-dynatrace/main.go @@ -0,0 +1,90 @@ +// Copyright 2026 The OpenChoreo Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + app "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal" + "github.com/openchoreo/community-modules/observability-logs-dynatrace/internal/dynatrace" +) + +func main() { + cfg, err := app.LoadConfig() + if err != nil { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + logger.Error("Failed to load configuration", slog.Any("error", err)) + os.Exit(1) + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel})) + + logger.Info("Configuration loaded", + slog.String("logLevel", cfg.LogLevel.String()), + slog.String("platformUrl", cfg.PlatformURL), + slog.String("authMode", cfg.AuthMode), + slog.String("containerLogsSource", cfg.ContainerLogsSource), + slog.String("auditLogsSource", cfg.AuditLogsSource), + slog.String("auditBucket", cfg.AuditBucket), + slog.String("queryTimeout", cfg.QueryTimeout.String()), + slog.String("serverPort", cfg.ServerPort), + ) + + var tokens dynatrace.TokenSource + if cfg.AuthMode == app.AuthModeOAuth { + tokens = dynatrace.NewOAuthTokenSource(cfg.OAuth, nil) + } else { + tokens = dynatrace.StaticToken(cfg.PlatformToken) + } + + client := dynatrace.NewClient(dynatrace.Config{ + PlatformURL: cfg.PlatformURL, + ContainerLogsSource: cfg.ContainerLogsSource, + AuditLogsSource: cfg.AuditLogsSource, + AuditBucket: cfg.AuditBucket, + QueryTimeout: cfg.QueryTimeout, + }, tokens, nil, logger) + + // Verify the token and its scopes up-front so the pod crashes and restarts rather than + // silently serving failing queries. + pingCtx, pingCancel := context.WithTimeout(context.Background(), 30*time.Second) + err = client.Ping(pingCtx) + pingCancel() + if err != nil { + logger.Error("Failed to query Dynatrace Grail; check DT_PLATFORM_URL, the credentials and their storage:logs:read / storage:buckets:read scopes", + slog.Any("error", err)) + os.Exit(1) + } + logger.Info("Connected to Dynatrace Grail") + + srv := app.NewServer(cfg.ServerPort, app.NewLogsHandler(client, logger), logger) + + go func() { + if err := srv.Start(); err != nil { + logger.Error("Server error", slog.Any("error", err)) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info("Shutting down gracefully") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + logger.Error("Error during shutdown", slog.Any("error", err)) + os.Exit(1) + } + + logger.Info("Server stopped") +} diff --git a/observability-logs-dynatrace/module.yaml b/observability-logs-dynatrace/module.yaml new file mode 100644 index 00000000..6195602e --- /dev/null +++ b/observability-logs-dynatrace/module.yaml @@ -0,0 +1,11 @@ +# Copyright 2026 The OpenChoreo Authors +# SPDX-License-Identifier: Apache-2.0 + +# Module manifest used by the CI workflow to discover Docker images to build. +# Each entry defines the image name, build context, Dockerfile path, and the +# corresponding field in helm/values.yaml to update with the published image URI. + +images: + - name: observability-logs-dynatrace-adapter + context: . + dockerfile: Dockerfile From eb8194efdaef6d965e33540f1c8a431db6cf2b3a Mon Sep 17 00:00:00 2001 From: Suvin Nimnaka Date: Fri, 2 Oct 2026 10:11:27 +0530 Subject: [PATCH 2/3] Address review comments on the Dynatrace logs module - Require https for DT_PLATFORM_URL and DT_OAUTH_TOKEN_URL, the URLs the adapter sends credentials to. adapter.allowInsecureHttp (DT_ALLOW_INSECURE_HTTP) opts out for test doubles and logs a warning. - Reject a blank workflowRunName in QueryLogs, which otherwise returned every workflow run in the namespace. - Return 400 for an inverted time window from the platform log handlers. - Derive the server WriteTimeout from the query timeout, so a response is not dropped when events run their tie-extension query after the page. - Remove the Codecov badge from the README. Signed-off-by: Suvin Nimnaka --- observability-logs-dynatrace/README.md | 59 +++++++++---------- .../helm/templates/adapter/configmap.yaml | 1 + observability-logs-dynatrace/helm/values.yaml | 4 ++ .../internal/config.go | 31 +++++++++- .../internal/config_test.go | 33 +++++++++-- .../internal/handlers.go | 4 +- .../internal/handlers_test.go | 19 +++++- .../internal/platform_handlers.go | 14 +++++ .../internal/server.go | 19 +++--- observability-logs-dynatrace/main.go | 6 +- 10 files changed, 141 insertions(+), 49 deletions(-) diff --git a/observability-logs-dynatrace/README.md b/observability-logs-dynatrace/README.md index 2814e7d3..361628e3 100644 --- a/observability-logs-dynatrace/README.md +++ b/observability-logs-dynatrace/README.md @@ -1,7 +1,5 @@ # Observability Logs Module for Dynatrace -[![Codecov](https://codecov.io/gh/openchoreo/community-modules/branch/main/graph/badge.svg?flag=observability-logs-dynatrace)](https://app.codecov.io/gh/openchoreo/community-modules?flags%5B0%5D=observability-logs-dynatrace) - This module collects container logs using [Fluent Bit](https://fluentbit.io), ships them to [Dynatrace](https://www.dynatrace.com) through the Log Ingest API, and serves them back to the OpenChoreo Observer from [Grail](https://docs.dynatrace.com/docs/platform/grail) with DQL. It has two parts: @@ -267,34 +265,35 @@ Check the events collector logs for `Exporting failed`. Its endpoint must be `ht ## Configuration reference -| Value | Default | Description | -| ----------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `dynatrace.platformUrl` | Required | Grail API base URL, `https://.apps.dynatrace.com`. Required when `adapter.enabled=true`. | -| `dynatrace.ingestUrl` | Derived from `platformUrl` | Log ingest base URL. Set it to ship through an ActiveGate, or on clusters that run only Fluent Bit. | -| `dynatrace.ingest.authScheme` | `Api-Token` | `Api-Token` for an access token, `Bearer` for a platform or OAuth token with `openpipeline:logs:ingest`. | -| `dynatrace.ingest.tlsVerify` | `true` | Verify the ingest endpoint's TLS certificate. | -| `dynatrace.containerLogsSource` | `openchoreo-container-logs` | `log.source` stamped on container logs. Must match on every cluster reporting to one environment. | -| `dynatrace.auditLogsSource` | `openchoreo-audit-logs` | `log.source` stamped on audit records. Must differ from `containerLogsSource`. | -| `dynatrace.credentials.name` | `dynatrace-credentials` | Secret holding the Dynatrace credentials, in the release namespace. | -| `dynatrace.credentials.create` | `false` | Have the chart create the Secret from the inline values below. For trials only. | -| `auditLogs.enabled` | `false` | Route audit records to their own `log.source`. | -| `auditLogs.producers` | `openchoreo-api`, `observer` | The trusted-producer allowlist. See [Trusted producers](#trusted-producers). | -| `auditLogs.bucket` | `""` | Grail bucket the adapter reads audit records from. Empty reads every bucket the token can see. | -| `fluent-bit.enabled` | `true` | Toggle the Fluent Bit DaemonSet. | -| `fluentBitCustomizations.clusterInstance` | Required | Cluster name stamped on every record as `k8s.cluster.name`. Required when Fluent Bit is enabled. | -| `fluentBitCustomizations.excludePaths` | `/var/log/containers/*_kube-system_*.log` | Container log files that are not shipped. Fluent Bit's own logs are always excluded. | -| `fluentBitCustomizations.readFromHead` | `true` | Read container log files from the beginning on first start. | -| `adapter.enabled` | `true` | Toggle the adapter Deployment. | -| `adapter.authMode` | `platformToken` | `platformToken` or `oauth`. | -| `adapter.oauth.tokenUrl` | `https://sso.dynatrace.com/sso/oauth2/token` | OAuth token endpoint, when `authMode=oauth`. | -| `adapter.oauth.scope` | `storage:logs:read storage:buckets:read` | OAuth scopes requested. | -| `adapter.oauth.resource` | `""` | Optional resource URN, e.g. `urn:dtaccount:`. | -| `adapter.queryTimeout` | `30s` | Upper bound for one Grail query, including polling. | -| `adapter.logLevel` | `info` | `debug`, `info`, `warn` or `error`. | -| `adapter.image.repository` | `ghcr.io/openchoreo/observability-logs-dynatrace-adapter` | Adapter container image. | -| `adapter.image.tag` | Chart `appVersion` | Image tag. | -| `adapter.image.pullPolicy` | `IfNotPresent` | Image pull policy. | -| `adapter.resources` | `200m/256Mi` limits, `50m/128Mi` requests | Adapter resource requests and limits. | +| Value | Default | Description | +| ----------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dynatrace.platformUrl` | Required | Grail API base URL, `https://.apps.dynatrace.com`. Required when `adapter.enabled=true`. | +| `dynatrace.ingestUrl` | Derived from `platformUrl` | Log ingest base URL. Set it to ship through an ActiveGate, or on clusters that run only Fluent Bit. | +| `dynatrace.ingest.authScheme` | `Api-Token` | `Api-Token` for an access token, `Bearer` for a platform or OAuth token with `openpipeline:logs:ingest`. | +| `dynatrace.ingest.tlsVerify` | `true` | Verify the ingest endpoint's TLS certificate. | +| `dynatrace.containerLogsSource` | `openchoreo-container-logs` | `log.source` stamped on container logs. Must match on every cluster reporting to one environment. | +| `dynatrace.auditLogsSource` | `openchoreo-audit-logs` | `log.source` stamped on audit records. Must differ from `containerLogsSource`. | +| `dynatrace.credentials.name` | `dynatrace-credentials` | Secret holding the Dynatrace credentials, in the release namespace. | +| `dynatrace.credentials.create` | `false` | Have the chart create the Secret from the inline values below. For trials only. | +| `auditLogs.enabled` | `false` | Route audit records to their own `log.source`. | +| `auditLogs.producers` | `openchoreo-api`, `observer` | The trusted-producer allowlist. See [Trusted producers](#trusted-producers). | +| `auditLogs.bucket` | `""` | Grail bucket the adapter reads audit records from. Empty reads every bucket the token can see. | +| `fluent-bit.enabled` | `true` | Toggle the Fluent Bit DaemonSet. | +| `fluentBitCustomizations.clusterInstance` | Required | Cluster name stamped on every record as `k8s.cluster.name`. Required when Fluent Bit is enabled. | +| `fluentBitCustomizations.excludePaths` | `/var/log/containers/*_kube-system_*.log` | Container log files that are not shipped. Fluent Bit's own logs are always excluded. | +| `fluentBitCustomizations.readFromHead` | `true` | Read container log files from the beginning on first start. | +| `adapter.enabled` | `true` | Toggle the adapter Deployment. | +| `adapter.authMode` | `platformToken` | `platformToken` or `oauth`. | +| `adapter.oauth.tokenUrl` | `https://sso.dynatrace.com/sso/oauth2/token` | OAuth token endpoint, when `authMode=oauth`. | +| `adapter.oauth.scope` | `storage:logs:read storage:buckets:read` | OAuth scopes requested. | +| `adapter.oauth.resource` | `""` | Optional resource URN, e.g. `urn:dtaccount:`. | +| `adapter.queryTimeout` | `30s` | Upper bound for one Grail query, including polling. | +| `adapter.allowInsecureHttp` | `false` | Accept `http://` for `dynatrace.platformUrl` and `adapter.oauth.tokenUrl`. Credentials then travel in cleartext, so use it only for a test double. | +| `adapter.logLevel` | `info` | `debug`, `info`, `warn` or `error`. | +| `adapter.image.repository` | `ghcr.io/openchoreo/observability-logs-dynatrace-adapter` | Adapter container image. | +| `adapter.image.tag` | Chart `appVersion` | Image tag. | +| `adapter.image.pullPolicy` | `IfNotPresent` | Image pull policy. | +| `adapter.resources` | `200m/256Mi` limits, `50m/128Mi` requests | Adapter resource requests and limits. | ## Dependencies diff --git a/observability-logs-dynatrace/helm/templates/adapter/configmap.yaml b/observability-logs-dynatrace/helm/templates/adapter/configmap.yaml index 16e31ece..b2422ecb 100644 --- a/observability-logs-dynatrace/helm/templates/adapter/configmap.yaml +++ b/observability-logs-dynatrace/helm/templates/adapter/configmap.yaml @@ -15,6 +15,7 @@ data: DT_PLATFORM_URL: {{ trimSuffix "/" .Values.dynatrace.platformUrl | quote }} DT_AUTH_MODE: {{ .Values.adapter.authMode | quote }} DT_QUERY_TIMEOUT: {{ .Values.adapter.queryTimeout | quote }} + DT_ALLOW_INSECURE_HTTP: {{ .Values.adapter.allowInsecureHttp | quote }} DT_CONTAINER_LOGS_SOURCE: {{ .Values.dynatrace.containerLogsSource | quote }} DT_AUDIT_LOGS_SOURCE: {{ .Values.dynatrace.auditLogsSource | quote }} DT_AUDIT_BUCKET: {{ .Values.auditLogs.bucket | quote }} diff --git a/observability-logs-dynatrace/helm/values.yaml b/observability-logs-dynatrace/helm/values.yaml index 2fe0baf1..ad1289f7 100644 --- a/observability-logs-dynatrace/helm/values.yaml +++ b/observability-logs-dynatrace/helm/values.yaml @@ -177,6 +177,10 @@ adapter: # Upper bound for one Grail query, including polling. queryTimeout: "30s" + # Accept http:// for dynatrace.platformUrl and adapter.oauth.tokenUrl. The adapter sends + # its credentials to both, so leave this off outside of a test double. + allowInsecureHttp: false + logLevel: "info" image: diff --git a/observability-logs-dynatrace/internal/config.go b/observability-logs-dynatrace/internal/config.go index 5a58d720..2c1b469a 100644 --- a/observability-logs-dynatrace/internal/config.go +++ b/observability-logs-dynatrace/internal/config.go @@ -30,6 +30,10 @@ type Config struct { AuthMode string QueryTimeout time.Duration + // AllowInsecureHTTP accepts http:// for the credential-bearing URLs. For test doubles + // only: credentials then cross the network in cleartext. + AllowInsecureHTTP bool + PlatformToken string OAuth dynatrace.OAuthConfig @@ -62,12 +66,17 @@ func LoadConfig() (*Config, error) { return nil, fmt.Errorf("invalid SERVER_PORT: %w", err) } + allowInsecure, err := strconv.ParseBool(getEnv("DT_ALLOW_INSECURE_HTTP", "false")) + if err != nil { + return nil, fmt.Errorf("DT_ALLOW_INSECURE_HTTP must be true or false: %w", err) + } + cfg.AllowInsecureHTTP = allowInsecure + if cfg.PlatformURL == "" { return nil, fmt.Errorf("environment variable DT_PLATFORM_URL is required") } - u, err := url.Parse(cfg.PlatformURL) - if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { - return nil, fmt.Errorf("DT_PLATFORM_URL must be an http(s) URL with a host, got: %q", cfg.PlatformURL) + if err := checkCredentialURL("DT_PLATFORM_URL", cfg.PlatformURL, cfg.AllowInsecureHTTP); err != nil { + return nil, err } timeout, err := time.ParseDuration(getEnv("DT_QUERY_TIMEOUT", "30s")) @@ -85,6 +94,9 @@ func LoadConfig() (*Config, error) { if cfg.OAuth.ClientID == "" || cfg.OAuth.ClientSecret == "" { return nil, fmt.Errorf("DT_OAUTH_CLIENT_ID and DT_OAUTH_CLIENT_SECRET are required when DT_AUTH_MODE=%s", AuthModeOAuth) } + if err := checkCredentialURL("DT_OAUTH_TOKEN_URL", cfg.OAuth.TokenURL, cfg.AllowInsecureHTTP); err != nil { + return nil, err + } default: return nil, fmt.Errorf("DT_AUTH_MODE must be %q or %q, got %q", AuthModePlatformToken, AuthModeOAuth, cfg.AuthMode) } @@ -96,6 +108,19 @@ func LoadConfig() (*Config, error) { return cfg, nil } +// checkCredentialURL rejects a URL credentials are sent to unless it is https with a host. +// allowInsecure also accepts http. +func checkCredentialURL(name, raw string, allowInsecure bool) error { + u, err := url.Parse(raw) + if err == nil && u.Host != "" && (u.Scheme == "https" || (allowInsecure && u.Scheme == "http")) { + return nil + } + if allowInsecure { + return fmt.Errorf("%s must be an http(s) URL with a host, got: %q", name, raw) + } + return fmt.Errorf("%s must be an https URL with a host, got: %q (set DT_ALLOW_INSECURE_HTTP=true to allow http)", name, raw) +} + func parseLogLevel(level string) slog.Level { switch strings.ToUpper(level) { case "DEBUG": diff --git a/observability-logs-dynatrace/internal/config_test.go b/observability-logs-dynatrace/internal/config_test.go index 46649c04..0691e8a9 100644 --- a/observability-logs-dynatrace/internal/config_test.go +++ b/observability-logs-dynatrace/internal/config_test.go @@ -14,7 +14,7 @@ func setEnv(t *testing.T, env map[string]string) { for _, k := range []string{ "SERVER_PORT", "LOG_LEVEL", "DT_PLATFORM_URL", "DT_AUTH_MODE", "DT_PLATFORM_TOKEN", "DT_QUERY_TIMEOUT", "DT_OAUTH_CLIENT_ID", "DT_OAUTH_CLIENT_SECRET", "DT_OAUTH_TOKEN_URL", "DT_OAUTH_SCOPE", "DT_OAUTH_RESOURCE", - "DT_CONTAINER_LOGS_SOURCE", "DT_AUDIT_LOGS_SOURCE", "DT_AUDIT_BUCKET", + "DT_CONTAINER_LOGS_SOURCE", "DT_AUDIT_LOGS_SOURCE", "DT_AUDIT_BUCKET", "DT_ALLOW_INSECURE_HTTP", } { t.Setenv(k, "") } @@ -31,11 +31,26 @@ func TestLoadConfigDefaults(t *testing.T) { } if cfg.PlatformURL != "https://abc.apps.dynatrace.com" || cfg.ServerPort != "9098" || cfg.AuthMode != AuthModePlatformToken || cfg.QueryTimeout != 30*time.Second || cfg.ContainerLogsSource != "openchoreo-container-logs" || - cfg.AuditLogsSource != "openchoreo-audit-logs" || cfg.LogLevel != slog.LevelInfo { + cfg.AuditLogsSource != "openchoreo-audit-logs" || cfg.LogLevel != slog.LevelInfo || cfg.AllowInsecureHTTP { t.Errorf("unexpected defaults %+v", cfg) } } +func TestLoadConfigAllowInsecureHTTP(t *testing.T) { + setEnv(t, map[string]string{ + "DT_PLATFORM_URL": "http://dtmock.dt-mock:8080", "DT_AUTH_MODE": "oauth", + "DT_OAUTH_CLIENT_ID": "id", "DT_OAUTH_CLIENT_SECRET": "s", "DT_OAUTH_TOKEN_URL": "http://dtmock.dt-mock:8080/token", + "DT_ALLOW_INSECURE_HTTP": "true", + }) + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if !cfg.AllowInsecureHTTP || cfg.PlatformURL != "http://dtmock.dt-mock:8080" { + t.Errorf("unexpected %+v", cfg) + } +} + func TestLoadConfigOAuth(t *testing.T) { setEnv(t, map[string]string{ "DT_PLATFORM_URL": "https://abc.apps.dynatrace.com", "DT_AUTH_MODE": "oauth", @@ -64,9 +79,17 @@ func TestLoadConfigErrors(t *testing.T) { return m } for name, env := range map[string]map[string]string{ - "missing url": {"DT_PLATFORM_TOKEN": "tok"}, - "bad url": with(map[string]string{"DT_PLATFORM_URL": "abc.apps.dynatrace.com"}), - "ftp url": with(map[string]string{"DT_PLATFORM_URL": "ftp://abc"}), + "missing url": {"DT_PLATFORM_TOKEN": "tok"}, + "bad url": with(map[string]string{"DT_PLATFORM_URL": "abc.apps.dynatrace.com"}), + "ftp url": with(map[string]string{"DT_PLATFORM_URL": "ftp://abc"}), + "http url": with(map[string]string{"DT_PLATFORM_URL": "http://abc.apps.dynatrace.com"}), + "no host url": with(map[string]string{"DT_PLATFORM_URL": "https://"}), + "insecure ftp": with(map[string]string{"DT_PLATFORM_URL": "ftp://abc", "DT_ALLOW_INSECURE_HTTP": "true"}), + "bad insecure": with(map[string]string{"DT_ALLOW_INSECURE_HTTP": "maybe"}), + "oauth http token": with(map[string]string{ + "DT_AUTH_MODE": "oauth", "DT_OAUTH_CLIENT_ID": "id", "DT_OAUTH_CLIENT_SECRET": "s", + "DT_OAUTH_TOKEN_URL": "http://sso.dynatrace.com/sso/oauth2/token", + }), "missing token": {"DT_PLATFORM_URL": "https://abc.apps.dynatrace.com"}, "oauth no client": with(map[string]string{"DT_AUTH_MODE": "oauth"}), "bad auth mode": with(map[string]string{"DT_AUTH_MODE": "basic"}), diff --git a/observability-logs-dynatrace/internal/handlers.go b/observability-logs-dynatrace/internal/handlers.go index 9d340b98..d27d3c63 100644 --- a/observability-logs-dynatrace/internal/handlers.go +++ b/observability-logs-dynatrace/internal/handlers.go @@ -62,8 +62,8 @@ func (h *LogsHandler) QueryLogs(ctx context.Context, request gen.QueryLogsReques // A WorkflowSearchScope is identified by having a workflowRunName field. workflowScope, err := body.SearchScope.AsWorkflowSearchScope() if err == nil && workflowScope.WorkflowRunName != nil { - if strings.TrimSpace(workflowScope.Namespace) == "" { - return gen.QueryLogs400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("searchScope with a valid namespace is required")}, nil + if strings.TrimSpace(workflowScope.Namespace) == "" || strings.TrimSpace(*workflowScope.WorkflowRunName) == "" { + return gen.QueryLogs400JSONResponse{Title: ptr(gen.BadRequest), Message: ptr("searchScope with a valid namespace and workflowRunName is required")}, nil } params := dynatrace.WorkflowLogsParams{ diff --git a/observability-logs-dynatrace/internal/handlers_test.go b/observability-logs-dynatrace/internal/handlers_test.go index b074e8dd..8e100c07 100644 --- a/observability-logs-dynatrace/internal/handlers_test.go +++ b/observability-logs-dynatrace/internal/handlers_test.go @@ -115,7 +115,15 @@ func (f *fakeBackend) GetAuditLogFilterValues(_ context.Context, p dynatrace.Aud func newTestServer(t *testing.T, backend *fakeBackend) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - return NewServer("0", NewLogsHandler(backend, logger), logger).Handler() + return NewServer("0", 30*time.Second, NewLogsHandler(backend, logger), logger).Handler() +} + +func TestWriteTimeoutCoversSequentialQueries(t *testing.T) { + for _, q := range []time.Duration{30 * time.Second, 2 * time.Minute} { + if got := NewServer("0", q, nil, nil).httpServer.WriteTimeout; got <= 2*q { + t.Errorf("query timeout %s: write timeout %s does not cover two queries", q, got) + } + } } func do(t *testing.T, h http.Handler, method, path, body string) (int, map[string]any) { @@ -176,6 +184,7 @@ func TestQueryLogsBadRequests(t *testing.T) { for name, body := range map[string]string{ "no namespace": `{` + window + `,"searchScope":{"namespace":""}}`, "workflow no namespace": `{` + window + `,"searchScope":{"namespace":" ","workflowRunName":"r"}}`, + "workflow no run": `{` + window + `,"searchScope":{"namespace":"d","workflowRunName":" "}}`, "inverted window": `{"startTime":"2026-01-01T01:00:00Z","endTime":"2026-01-01T00:00:00Z","searchScope":{"namespace":"d"}}`, } { if code, _ := do(t, h, http.MethodPost, "/api/v1/logs/query", body); code != http.StatusBadRequest { @@ -268,6 +277,10 @@ func TestQueryPlatformLogs(t *testing.T) { if code, _ := do(t, h, http.MethodPost, "/api/v1alpha1/platform-logs/query", `{`+window+`,"labels":{"a=b":"x"}}`); code != http.StatusBadRequest { t.Errorf("invalid label key: status %d", code) } + if code, _ := do(t, h, http.MethodPost, "/api/v1alpha1/platform-logs/query", + `{"startTime":"2026-01-01T01:00:00Z","endTime":"2026-01-01T00:00:00Z"}`); code != http.StatusBadRequest { + t.Errorf("inverted window: status %d", code) + } } func TestQueryPlatformLogFilterValues(t *testing.T) { @@ -287,6 +300,10 @@ func TestQueryPlatformLogFilterValues(t *testing.T) { if body["filter"] != "namespace" { t.Errorf("filter not echoed: %v", body) } + if code, _ := do(t, h, http.MethodPost, "/api/v1alpha1/platform-logs/filter-values", + `{"query":{"startTime":"2026-01-01T01:00:00Z","endTime":"2026-01-01T00:00:00Z"},"filter":"namespace"}`); code != http.StatusBadRequest { + t.Errorf("inverted window: status %d", code) + } } func TestQueryAuditLogs(t *testing.T) { diff --git a/observability-logs-dynatrace/internal/platform_handlers.go b/observability-logs-dynatrace/internal/platform_handlers.go index 0f2e2390..2a1518a4 100644 --- a/observability-logs-dynatrace/internal/platform_handlers.go +++ b/observability-logs-dynatrace/internal/platform_handlers.go @@ -24,6 +24,13 @@ func (h *LogsHandler) QueryPlatformLogs( }, nil } + if !request.Body.EndTime.After(request.Body.StartTime) { + return gen.QueryPlatformLogs400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("endTime must be after startTime"), + }, nil + } + if key, ok := firstInvalidLabelKey(request.Body); !ok { return gen.QueryPlatformLogs400JSONResponse{ Title: ptr(gen.BadRequest), @@ -88,6 +95,13 @@ func (h *LogsHandler) QueryPlatformLogFilterValues( } body := request.Body + if !body.Query.EndTime.After(body.Query.StartTime) { + return gen.QueryPlatformLogFilterValues400JSONResponse{ + Title: ptr(gen.BadRequest), + Message: ptr("query.endTime must be after query.startTime"), + }, nil + } + if key, ok := firstInvalidLabelKey(&body.Query); !ok { return gen.QueryPlatformLogFilterValues400JSONResponse{ Title: ptr(gen.BadRequest), diff --git a/observability-logs-dynatrace/internal/server.go b/observability-logs-dynatrace/internal/server.go index bd72da5c..c87d491c 100644 --- a/observability-logs-dynatrace/internal/server.go +++ b/observability-logs-dynatrace/internal/server.go @@ -20,20 +20,25 @@ type Server struct { logger *slog.Logger } +// writeTimeout bounds a response by the slowest request path: events run the page and count +// queries in parallel and then the tie extension, so two query timeouts back to back, plus a +// margin for encoding and the network. +func writeTimeout(queryTimeout time.Duration) time.Duration { + return 2*queryTimeout + 15*time.Second +} + // NewServer wires the handler into the generated router. -func NewServer(port string, logsHandler *LogsHandler, logger *slog.Logger) *Server { +func NewServer(port string, queryTimeout time.Duration, logsHandler *LogsHandler, logger *slog.Logger) *Server { strictHandler := gen.NewStrictHandler(logsHandler, nil) mux := http.NewServeMux() handler := gen.HandlerFromMux(strictHandler, mux) httpServer := &http.Server{ - Addr: ":" + port, - Handler: handler, - ReadTimeout: 15 * time.Second, - // Longer than the other adapters: a Grail query over a wide window can take a - // while, and the page, count and timeline queries all have to finish. - WriteTimeout: 60 * time.Second, + Addr: ":" + port, + Handler: handler, + ReadTimeout: 15 * time.Second, + WriteTimeout: writeTimeout(queryTimeout), IdleTimeout: 60 * time.Second, } diff --git a/observability-logs-dynatrace/main.go b/observability-logs-dynatrace/main.go index b89c9102..a24a9438 100644 --- a/observability-logs-dynatrace/main.go +++ b/observability-logs-dynatrace/main.go @@ -43,6 +43,10 @@ func main() { tokens = dynatrace.StaticToken(cfg.PlatformToken) } + if cfg.AllowInsecureHTTP { + logger.Warn("DT_ALLOW_INSECURE_HTTP is set: credentials may be sent over plain http. Use this for test doubles only") + } + client := dynatrace.NewClient(dynatrace.Config{ PlatformURL: cfg.PlatformURL, ContainerLogsSource: cfg.ContainerLogsSource, @@ -63,7 +67,7 @@ func main() { } logger.Info("Connected to Dynatrace Grail") - srv := app.NewServer(cfg.ServerPort, app.NewLogsHandler(client, logger), logger) + srv := app.NewServer(cfg.ServerPort, cfg.QueryTimeout, app.NewLogsHandler(client, logger), logger) go func() { if err := srv.Start(); err != nil { From 035c62a1e2d5513ce93bfb65a5b370db2a17507b Mon Sep 17 00:00:00 2001 From: Suvin Nimnaka Date: Fri, 2 Oct 2026 10:25:09 +0530 Subject: [PATCH 3/3] address review comments Signed-off-by: Suvin Nimnaka --- .../internal/dynatrace/auth.go | 2 +- .../internal/dynatrace/client.go | 13 ++++++- .../internal/dynatrace/client_test.go | 38 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/observability-logs-dynatrace/internal/dynatrace/auth.go b/observability-logs-dynatrace/internal/dynatrace/auth.go index 19bda9ab..86d8f77a 100644 --- a/observability-logs-dynatrace/internal/dynatrace/auth.go +++ b/observability-logs-dynatrace/internal/dynatrace/auth.go @@ -72,7 +72,7 @@ func NewOAuthTokenSource(cfg OAuthConfig, httpClient *http.Client) *OAuthTokenSo cfg.Scope = DefaultOAuthScope } if httpClient == nil { - httpClient = &http.Client{Timeout: 15 * time.Second} + httpClient = newNoRedirectClient(15 * time.Second) } return &OAuthTokenSource{cfg: cfg, httpClient: httpClient, now: time.Now} } diff --git a/observability-logs-dynatrace/internal/dynatrace/client.go b/observability-logs-dynatrace/internal/dynatrace/client.go index fcb4c347..774e9c54 100644 --- a/observability-logs-dynatrace/internal/dynatrace/client.go +++ b/observability-logs-dynatrace/internal/dynatrace/client.go @@ -63,10 +63,21 @@ type Client struct { auditBucket string } +// Returns an HTTP client that answers a redirect with the 3xx response +// instead of following it. +func newNoRedirectClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + // NewClient returns a client for the given environment. func NewClient(cfg Config, tokens TokenSource, httpClient *http.Client, logger *slog.Logger) *Client { if httpClient == nil { - httpClient = &http.Client{Timeout: 60 * time.Second} + httpClient = newNoRedirectClient(60 * time.Second) } timeout := cfg.QueryTimeout if timeout <= 0 { diff --git a/observability-logs-dynatrace/internal/dynatrace/client_test.go b/observability-logs-dynatrace/internal/dynatrace/client_test.go index f527e1e2..20c4bcb0 100644 --- a/observability-logs-dynatrace/internal/dynatrace/client_test.go +++ b/observability-logs-dynatrace/internal/dynatrace/client_test.go @@ -171,6 +171,44 @@ func TestNewOAuthTokenSourceDefaults(t *testing.T) { if s.cfg.TokenURL != DefaultOAuthTokenURL || s.cfg.Scope != DefaultOAuthScope { t.Errorf("defaults not applied: %+v", s.cfg) } + if s.httpClient.Timeout != 15*time.Second { + t.Errorf("default timeout %s", s.httpClient.Timeout) + } +} + +// redirectTrap answers every request with a 307 to a second server, and counts the requests +// that second server receives. +func redirectTrap(t *testing.T) (redirector *httptest.Server, followed *atomic.Int32) { + t.Helper() + followed = &atomic.Int32{} + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { followed.Add(1) })) + t.Cleanup(target.Close) + redirector = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+r.URL.Path, http.StatusTemporaryRedirect) + })) + t.Cleanup(redirector.Close) + return redirector, followed +} + +func TestDefaultClientsDoNotFollowRedirects(t *testing.T) { + srv, followed := redirectTrap(t) + + s := NewOAuthTokenSource(OAuthConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "secret"}, nil) + if _, err := s.Token(context.Background()); err == nil || !strings.Contains(err.Error(), "307") { + t.Errorf("OAuth: expected a 307 error, got %v", err) + } + + c := NewClient(Config{PlatformURL: srv.URL}, StaticToken("t"), nil, discardLogger()) + if c.httpClient.Timeout != 60*time.Second { + t.Errorf("default timeout %s", c.httpClient.Timeout) + } + if err := c.Ping(context.Background()); err == nil || !strings.Contains(err.Error(), "307") { + t.Errorf("query: expected a 307 error, got %v", err) + } + + if n := followed.Load(); n != 0 { + t.Errorf("redirect target received %d requests", n) + } } func TestGetComponentLogs(t *testing.T) {