Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions adventures/03-the-ai-observatory/beginner/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions adventures/03-the-ai-observatory/expert/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions adventures/03-the-ai-observatory/intermediate/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions adventures/04-blind-by-design/beginner/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions adventures/04-blind-by-design/intermediate/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions infra/tracker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
31 changes: 31 additions & 0 deletions infra/tracker/README.md
Original file line number Diff line number Diff line change
@@ -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=<your-tenant-url> \
--set-secrets DT_API_TOKEN=offon-challenge-tracker-dt-api-token:latest
```

To update the service, re-run the deploy command.
3 changes: 3 additions & 0 deletions infra/tracker/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/dynatrace-oss/open-ecosystem-challenges/infra/tracker

go 1.26
120 changes: 120 additions & 0 deletions infra/tracker/main.go
Original file line number Diff line number Diff line change
@@ -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))
}
107 changes: 107 additions & 0 deletions lib/scripts/tracker-legacy.sh
Original file line number Diff line number Diff line change
@@ -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}'
)"
}
Loading