diff --git a/.gitignore b/.gitignore index ad23489..051e98e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,11 @@ target/ # OpenTelemetry Java Agent (downloaded per-Codespace by post-create.sh) tools/ +# Go build artifacts +infra/tracker/tracker + +# Per-Codespace session ID (generated by tracker.sh, never committed) +.offon-session-id + # Custom ignores/includes .prompts diff --git a/adventures/03-the-ai-observatory/beginner/verify.sh b/adventures/03-the-ai-observatory/beginner/verify.sh index f9b6cc7..489edc6 100755 --- a/adventures/03-the-ai-observatory/beginner/verify.sh +++ b/adventures/03-the-ai-observatory/beginner/verify.sh @@ -5,6 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/../../../lib/scripts/loader.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../lib/scripts/tracker-legacy.sh" OBJECTIVE="By the end of this level, you should: diff --git a/adventures/03-the-ai-observatory/expert/verify.sh b/adventures/03-the-ai-observatory/expert/verify.sh index 25a1845..e041970 100755 --- a/adventures/03-the-ai-observatory/expert/verify.sh +++ b/adventures/03-the-ai-observatory/expert/verify.sh @@ -5,6 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/../../../lib/scripts/loader.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../lib/scripts/tracker-legacy.sh" OBJECTIVE="By the end of this level, you should: diff --git a/adventures/03-the-ai-observatory/intermediate/verify.sh b/adventures/03-the-ai-observatory/intermediate/verify.sh index f93ec9e..76ddcf7 100755 --- a/adventures/03-the-ai-observatory/intermediate/verify.sh +++ b/adventures/03-the-ai-observatory/intermediate/verify.sh @@ -5,6 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/../../../lib/scripts/loader.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../lib/scripts/tracker-legacy.sh" OBJECTIVE="By the end of this level, you should: diff --git a/adventures/04-blind-by-design/beginner/verify.sh b/adventures/04-blind-by-design/beginner/verify.sh index bdbb723..5e7d126 100755 --- a/adventures/04-blind-by-design/beginner/verify.sh +++ b/adventures/04-blind-by-design/beginner/verify.sh @@ -5,6 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/../../../lib/scripts/loader.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../lib/scripts/tracker-legacy.sh" OBJECTIVE="By the end of this level, you should: diff --git a/adventures/04-blind-by-design/intermediate/verify.sh b/adventures/04-blind-by-design/intermediate/verify.sh index f2a1c8e..06207bc 100755 --- a/adventures/04-blind-by-design/intermediate/verify.sh +++ b/adventures/04-blind-by-design/intermediate/verify.sh @@ -5,6 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/../../../lib/scripts/loader.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../lib/scripts/tracker-legacy.sh" OBJECTIVE="By the end of this level, the lab hits each of these observable outcomes: diff --git a/adventures/planned/00-lex-imperfecta/beginner/.devcontainer/00-lex-imperfecta_01-beginner/post-create.sh b/adventures/planned/00-lex-imperfecta/beginner/.devcontainer/00-lex-imperfecta_01-beginner/post-create.sh index a2b7760..6063a72 100755 --- a/adventures/planned/00-lex-imperfecta/beginner/.devcontainer/00-lex-imperfecta_01-beginner/post-create.sh +++ b/adventures/planned/00-lex-imperfecta/beginner/.devcontainer/00-lex-imperfecta_01-beginner/post-create.sh @@ -6,7 +6,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck disable=SC1091 source "$REPO_ROOT/lib/scripts/tracker.sh" set_tracking_context "lex-imperfecta" "beginner" -track_codespace_created +track_container_created "$REPO_ROOT/lib/shared/init.sh" --version v0.17.0 "$REPO_ROOT/lib/kubernetes/init.sh" \ diff --git a/adventures/planned/00-lex-imperfecta/beginner/.devcontainer/00-lex-imperfecta_01-beginner/post-start.sh b/adventures/planned/00-lex-imperfecta/beginner/.devcontainer/00-lex-imperfecta_01-beginner/post-start.sh index 27fc1a8..2af937b 100755 --- a/adventures/planned/00-lex-imperfecta/beginner/.devcontainer/00-lex-imperfecta_01-beginner/post-start.sh +++ b/adventures/planned/00-lex-imperfecta/beginner/.devcontainer/00-lex-imperfecta_01-beginner/post-start.sh @@ -17,4 +17,4 @@ kubectl apply -f "$CHALLENGE_DIR/manifests/pods/" 2>&1 || true # shellcheck disable=SC1091 source "$REPO_ROOT/lib/scripts/tracker.sh" set_tracking_context "lex-imperfecta" "beginner" -track_codespace_initialized +track_container_initialized diff --git a/infra/tracker/Dockerfile b/infra/tracker/Dockerfile new file mode 100644 index 0000000..478249b --- /dev/null +++ b/infra/tracker/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server . + +FROM gcr.io/distroless/static-debian12 +COPY --from=builder /app/server /server +ENTRYPOINT ["/server"] diff --git a/infra/tracker/README.md b/infra/tracker/README.md new file mode 100644 index 0000000..9fb4afa --- /dev/null +++ b/infra/tracker/README.md @@ -0,0 +1,31 @@ +# Tracker + +A Cloud Run service that receives bizevent payloads from challenge Codespaces and forwards them to a Dynatrace tenant. + +It validates that every incoming event has `type: offon-challenges`, a known `action`, and all required fields (`adventure`, `level`, `session.id`) before ingesting. Anything else is rejected with a 400. + +## Deployment + +Deployed manually via the Google Cloud CLI. One-time secret setup: + +```sh +echo -n "dt0c01.xxx" | gcloud secrets create offon-challenge-tracker-dt-api-token --data-file=- + +PROJECT_NUMBER=$(gcloud projects describe $(gcloud config get-value project) --format='value(projectNumber)') +gcloud secrets add-iam-policy-binding offon-challenge-tracker-dt-api-token \ + --member="serviceAccount:$PROJECT_NUMBER-compute@developer.gserviceaccount.com" \ + --role="roles/secretmanager.secretAccessor" +``` + +Deploy: + +```sh +gcloud run deploy offon-challenge-tracker \ + --source infra/tracker \ + --region europe-west1 \ + --allow-unauthenticated \ + --set-env-vars DT_TENANT_URL= \ + --set-secrets DT_API_TOKEN=offon-challenge-tracker-dt-api-token:latest +``` + +To update the service, re-run the deploy command. diff --git a/infra/tracker/go.mod b/infra/tracker/go.mod new file mode 100644 index 0000000..0013747 --- /dev/null +++ b/infra/tracker/go.mod @@ -0,0 +1,3 @@ +module github.com/dynatrace-oss/open-ecosystem-challenges/infra/tracker + +go 1.26 diff --git a/infra/tracker/main.go b/infra/tracker/main.go new file mode 100644 index 0000000..b9c67ee --- /dev/null +++ b/infra/tracker/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "os" +) + +const ( + allowedType = "offon-challenges" + dtAPIPath = "/api/v2/bizevents/ingest" +) + +var validActions = map[string]bool{ + "container.created": true, + "container.initialized": true, + "verification.completed": true, +} + +type bizEvent struct { + Type string `json:"type"` + Action string `json:"action"` + Adventure string `json:"adventure"` + Level string `json:"level"` + SessionID string `json:"session.id"` + GithubUser string `json:"github.user,omitempty"` + GithubRepo string `json:"github.repo,omitempty"` + Status string `json:"status,omitempty"` + FailedChecks []string `json:"failed_checks,omitempty"` +} + +func (e *bizEvent) validate() string { + if e.Type != allowedType { + return "type must be " + allowedType + } + if !validActions[e.Action] { + return "unknown action: " + e.Action + } + if e.Adventure == "" { + return "adventure is required" + } + if e.Level == "" { + return "level is required" + } + if e.SessionID == "" { + return "session.id is required" + } + return "" +} + +func handler(dtURL, dtToken string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var event bizEvent + if err := json.Unmarshal(body, &event); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + if msg := event.validate(); msg != "" { + http.Error(w, msg, http.StatusBadRequest) + return + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, dtURL+dtAPIPath, bytes.NewReader(body)) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + req.Header.Set("Authorization", "Api-Token "+dtToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("dynatrace ingest error: %v", err) + http.Error(w, "failed to forward event", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + log.Printf("dynatrace returned %d: %s", resp.StatusCode, respBody) + http.Error(w, "dynatrace ingest failed", http.StatusBadGateway) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +func main() { + dtURL := os.Getenv("DT_TENANT_URL") + dtToken := os.Getenv("DT_API_TOKEN") + if dtURL == "" || dtToken == "" { + log.Fatal("DT_TENANT_URL and DT_API_TOKEN must be set") + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + http.HandleFunc("/", handler(dtURL, dtToken)) + log.Printf("listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/lib/scripts/tracker-legacy.sh b/lib/scripts/tracker-legacy.sh new file mode 100644 index 0000000..35513a0 --- /dev/null +++ b/lib/scripts/tracker-legacy.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# DEPRECATED - used by adventures 01-04 only. New adventures use tracker.sh. +# tracker-legacy.sh - Sends bizevents to the old AWS Lambda codespace-tracker + +# Load tracking context from rc file (set by set_tracking_context) +# shellcheck disable=SC1090 +[[ -f ~/.bashrc ]] && source ~/.bashrc + +TRACKER_URL="https://grzxx1q7wd.execute-api.us-east-1.amazonaws.com/default/codespace-tracker" +EVENT_TYPE="open-ecosystem-challenges" + +# ----------------------------------------------------------------------------- +# Set tracking context (persists to rc files for all scripts) +# Usage: set_tracking_context "02-building-cloudhaven" "beginner" +# ----------------------------------------------------------------------------- +set_tracking_context() { + local adventure=$1 + local level=$2 + + export ADVENTURE="$adventure" + export LEVEL="$level" + + echo "export ADVENTURE=\"$adventure\"" >> ~/.bashrc + echo "export ADVENTURE=\"$adventure\"" >> ~/.zshrc + echo "export LEVEL=\"$level\"" >> ~/.bashrc + echo "export LEVEL=\"$level\"" >> ~/.zshrc +} + +# ----------------------------------------------------------------------------- +# Send an event to Dynatrace +# Usage: send_event "event.action" '{"extra": "fields"}' +# ----------------------------------------------------------------------------- +send_event() { + local action=$1 + local extra_fields=${2:-"{}"} + + # Build the payload using jq for proper JSON handling + local payload + payload=$(jq -n \ + --arg event_type "$EVENT_TYPE" \ + --arg action "$action" \ + --arg adventure "${ADVENTURE:-unknown}" \ + --arg level "${LEVEL:-unknown}" \ + --arg github_user "${GITHUB_USER:-unknown}" \ + --arg github_repo "${GITHUB_REPOSITORY:-unknown}" \ + --arg codespace_id "${CODESPACE_NAME:-local}" \ + --argjson extra "$extra_fields" \ + '{ + "type": $event_type, + "action": $action, + "adventure": $adventure, + "level": $level, + "github.user": $github_user, + "github.repo": $github_repo, + "codespace.id": $codespace_id + } + $extra' + ) + + # Send to tracker API (silent, don't fail the script) + curl -sS -X POST "$TRACKER_URL" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + >/dev/null 2>&1 || true +} + +# ----------------------------------------------------------------------------- +# Event: codespace.created +# Send when codespace starts provisioning (post-create.sh) +# ----------------------------------------------------------------------------- +track_codespace_created() { + send_event "codespace.created" +} + +# ----------------------------------------------------------------------------- +# Event: codespace.initialized +# Send when environment is ready (post-start.sh) +# ----------------------------------------------------------------------------- +track_codespace_initialized() { + send_event "codespace.initialized" +} + +# ----------------------------------------------------------------------------- +# Event: smoketest.completed +# Send when smoke test finishes +# Usage: track_smoketest_completed "success" '["check1","check2"]' +# ----------------------------------------------------------------------------- +track_smoketest_completed() { + local status=$1 + local failed_checks=${2:-"[]"} + + send_event "smoketest.completed" "$(jq -n \ + --arg status "$status" \ + --argjson failed_checks "$failed_checks" \ + '{status: $status, failed_checks: $failed_checks}' + )" +} + +track_verification_completed() { + local status=$1 + local failed_checks=${2:-"[]"} + + send_event "verification.completed" "$(jq -n \ + --arg status "$status" \ + --argjson failed_checks "$failed_checks" \ + '{status: $status, failed_checks: $failed_checks}' + )" +} diff --git a/lib/scripts/tracker.sh b/lib/scripts/tracker.sh index b42ea24..ce0c183 100644 --- a/lib/scripts/tracker.sh +++ b/lib/scripts/tracker.sh @@ -1,17 +1,13 @@ #!/usr/bin/env bash -# tracker.sh - Helper functions for sending bizevents to Dynatrace via codespace-tracker -# This script provides reusable functions for observability tracking across all adventures +# tracker.sh - Sends bizevents to the offon-challenge-tracker Cloud Run service -# Load tracking context from rc file (set by set_tracking_context) -# shellcheck disable=SC1090 -[[ -f ~/.bashrc ]] && source ~/.bashrc - -TRACKER_URL="https://grzxx1q7wd.execute-api.us-east-1.amazonaws.com/default/codespace-tracker" -EVENT_TYPE="open-ecosystem-challenges" +TRACKER_URL="https://offon-challenge-tracker-291758365471.europe-west1.run.app" +EVENT_TYPE="offon-challenges" +SESSION_ID_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/.offon-session-id" # ----------------------------------------------------------------------------- -# Set tracking context (persists to rc files for all scripts) -# Usage: set_tracking_context "02-building-cloudhaven" "beginner" +# Set tracking context and ensure a session ID exists +# Usage: set_tracking_context "00-lex-imperfecta" "beginner" # ----------------------------------------------------------------------------- set_tracking_context() { local adventure=$1 @@ -20,79 +16,54 @@ set_tracking_context() { export ADVENTURE="$adventure" export LEVEL="$level" - echo "export ADVENTURE=\"$adventure\"" >> ~/.bashrc - echo "export ADVENTURE=\"$adventure\"" >> ~/.zshrc - echo "export LEVEL=\"$level\"" >> ~/.bashrc - echo "export LEVEL=\"$level\"" >> ~/.zshrc + if [[ ! -f "$SESSION_ID_FILE" ]]; then + uuidgen > "$SESSION_ID_FILE" + fi + export OFFON_SESSION_ID + OFFON_SESSION_ID=$(cat "$SESSION_ID_FILE") } # ----------------------------------------------------------------------------- -# Send an event to Dynatrace +# Send an event to the tracker (silent, never fails the caller) # Usage: send_event "event.action" '{"extra": "fields"}' # ----------------------------------------------------------------------------- send_event() { local action=$1 local extra_fields=${2:-"{}"} - # Build the payload using jq for proper JSON handling local payload payload=$(jq -n \ --arg event_type "$EVENT_TYPE" \ --arg action "$action" \ --arg adventure "${ADVENTURE:-unknown}" \ --arg level "${LEVEL:-unknown}" \ - --arg github_user "${GITHUB_USER:-unknown}" \ - --arg github_repo "${GITHUB_REPOSITORY:-unknown}" \ - --arg codespace_id "${CODESPACE_NAME:-local}" \ + --arg session_id "${OFFON_SESSION_ID:-unknown}" \ + --arg github_user "${GITHUB_USER:-}" \ + --arg github_repo "${GITHUB_REPOSITORY:-}" \ --argjson extra "$extra_fields" \ '{ "type": $event_type, "action": $action, "adventure": $adventure, "level": $level, + "session.id": $session_id, "github.user": $github_user, - "github.repo": $github_repo, - "codespace.id": $codespace_id + "github.repo": $github_repo } + $extra' ) - # Send to tracker API (silent, don't fail the script) curl -sS -X POST "$TRACKER_URL" \ -H "Content-Type: application/json" \ -d "$payload" \ >/dev/null 2>&1 || true } -# ----------------------------------------------------------------------------- -# Event: codespace.created -# Send when codespace starts provisioning (post-create.sh) -# ----------------------------------------------------------------------------- -track_codespace_created() { - send_event "codespace.created" -} - -# ----------------------------------------------------------------------------- -# Event: codespace.initialized -# Send when environment is ready (post-start.sh) -# ----------------------------------------------------------------------------- -track_codespace_initialized() { - send_event "codespace.initialized" +track_container_created() { + send_event "container.created" } -# ----------------------------------------------------------------------------- -# Event: smoketest.completed -# Send when smoke test finishes -# Usage: track_smoketest_completed "success" '["check1","check2"]' -# ----------------------------------------------------------------------------- -track_smoketest_completed() { - local status=$1 - local failed_checks=${2:-"[]"} - - send_event "smoketest.completed" "$(jq -n \ - --arg status "$status" \ - --argjson failed_checks "$failed_checks" \ - '{status: $status, failed_checks: $failed_checks}' - )" +track_container_initialized() { + send_event "container.initialized" } track_verification_completed() {