diff --git a/.github/actions/cleanup-sources/action.yml b/.github/actions/cleanup-sources/action.yml new file mode 100644 index 0000000..03e26b6 --- /dev/null +++ b/.github/actions/cleanup-sources/action.yml @@ -0,0 +1,33 @@ +# Deletes the run's source and stale sources of earlier runs of the same test, and never fails the job. +# The logic is in run.sh, which reads only environment variables, so it runs the same way outside of Actions. +name: "Clean up Better Stack sources" +description: "Deletes this run's source and sources of earlier runs, named with the same prefix, that are older than stale-hours" + +inputs: + api-token: + description: "Telemetry API token of the team the sources are in" + required: true + prefix: + description: "Name prefix of this test's sources, only those are swept" + required: true + id: + description: "Id of this run's source, deleted whatever its age" + default: "" + stale-hours: + description: "Sources with the prefix older than this many hours are left over from earlier runs" + default: "3" + out: + description: "Directory for cleanup.md" + default: out + +runs: + using: composite + steps: + - shell: bash + env: + INPUT_API_TOKEN: ${{ inputs.api-token }} + INPUT_PREFIX: ${{ inputs.prefix }} + INPUT_ID: ${{ inputs.id }} + INPUT_STALE_HOURS: ${{ inputs.stale-hours }} + INPUT_OUT: ${{ inputs.out }} + run: bash "${{ github.action_path }}/run.sh" diff --git a/.github/actions/cleanup-sources/run.sh b/.github/actions/cleanup-sources/run.sh new file mode 100644 index 0000000..cd3b5b1 --- /dev/null +++ b/.github/actions/cleanup-sources/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Deletes this run's source, then what earlier runs of the same test left behind (a cancelled job never reaches its +# cleanup): every source whose name starts with the prefix and that is older than stale-hours. Younger sources may +# belong to a run still in progress and stay. Writes /cleanup.md, one line per deletion, and always exits 0: +# a failed deletion shows in the report, but never hides the test result. +# Env: INPUT_API_TOKEN, INPUT_PREFIX, INPUT_ID (optional), INPUT_STALE_HOURS (default 3), INPUT_OUT (default out). +set -euo pipefail +# A native jq.exe, as on Windows runners, ends its output lines with CRLF unless given -b. +if [ "${OS:-}" = Windows_NT ]; then jq() { command jq -b "$@"; }; fi + +API=https://telemetry.betterstack.com +OUT=${INPUT_OUT:-out} +mkdir -p "$OUT" +: > "$OUT/cleanup.md" +say() { echo "$1"; echo "$1" >> "$OUT/cleanup.md"; } +trap 'status=$?; [ "$status" = 0 ] || say "- ❌ cleanup stopped on an error, see the log of this step"; exit 0' EXIT + +delete() { + local code + code=$(curl -sS -m 60 -o /dev/null -w '%{http_code}' -X DELETE "$API/api/v1/sources/$1" -H "Authorization: Bearer $INPUT_API_TOKEN") || code=000 + case "$code" in + 204) say "- deleted $2" ;; + 404) say "- $2 was already gone" ;; + *) say "- ❌ could not delete $2 (HTTP $code)" ;; + esac +} + +say "### Cleanup" +say "" +[ -z "${INPUT_ID:-}" ] || delete "$INPUT_ID" "this run's source $INPUT_ID" + +# An empty prefix would sweep every source of the team. +[ -n "$INPUT_PREFIX" ] || { say "- ❌ no prefix given, so stale sources were not looked for"; exit 0; } +# The name filter matches anywhere in the name, so the prefix is checked again. The next-page link the API returns +# drops the filter, so pages are counted here instead of following it. +STALE="" +for ((page = 1; ; page++)); do + RES=$(curl -sS -m 60 -G "$API/api/v1/sources" --data-urlencode "name=$INPUT_PREFIX" -d per_page=250 -d "page=$page" -H "Authorization: Bearer $INPUT_API_TOKEN") || RES="" + jq -e '.data | type == "array"' <<<"$RES" >/dev/null 2>&1 || { say "- ❌ could not list sources, so stale ones were not deleted: $(head -c 300 <<<"${RES:-no response}")"; STALE=""; break; } + STALE+=$(jq -r --arg prefix "$INPUT_PREFIX" --arg id "${INPUT_ID:-}" --argjson hours "${INPUT_STALE_HOURS:-3}" '.data[] + | select((.attributes.name | startswith($prefix)) and .id != $id) + | select((.attributes.created_at | sub("\\.[0-9]+"; "") | fromdateiso8601) < now - $hours * 3600) + | "\(.id) \(.attributes.created_at) \(.attributes.name)"' <<<"$RES")$'\n' + [ "$(jq -r '.pagination.next' <<<"$RES")" != null ] || break +done +while read -r id created_at name; do + [ -n "$id" ] || continue + delete "$id" "stale source $id \`$name\` (created $created_at)" +done <<<"$STALE" + +[ "$(wc -l < "$OUT/cleanup.md")" -gt 2 ] || say "- nothing to delete" diff --git a/.github/actions/create-source/action.yml b/.github/actions/create-source/action.yml new file mode 100644 index 0000000..6e7311c --- /dev/null +++ b/.github/actions/create-source/action.yml @@ -0,0 +1,46 @@ +# Creates a Better Stack source through the Telemetry API and waits until ingest reliably accepts its token. +# The logic is in run.sh, which reads only environment variables, so it runs the same way outside of Actions. +name: "Create a Better Stack source" +description: "Creates a source, warms it up until ingest accepts its token and outputs where to send and read logs" + +inputs: + api-token: + description: "Telemetry API token of the team the source is created in" + required: true + name: + description: "Name of the new source" + required: true + platform: + description: "Platform of the new source, as the Telemetry API names it" + required: true + +outputs: + id: + description: "Source id" + value: ${{ steps.run.outputs.id }} + token: + description: "Source token for ingest (masked in the logs)" + value: ${{ steps.run.outputs.token }} + ingesting-host: + description: "Host to send logs to" + value: ${{ steps.run.outputs.ingesting-host }} + data-region: + description: "Data region the source stores its logs in" + value: ${{ steps.run.outputs.data-region }} + collection: + description: "Collection name for the SQL API, t_" + value: ${{ steps.run.outputs.collection }} + connect-host: + description: "SQL API host that reads this source" + value: ${{ steps.run.outputs.connect-host }} + +runs: + using: composite + steps: + - id: run + shell: bash + env: + INPUT_API_TOKEN: ${{ inputs.api-token }} + INPUT_NAME: ${{ inputs.name }} + INPUT_PLATFORM: ${{ inputs.platform }} + run: bash "${{ github.action_path }}/run.sh" diff --git a/.github/actions/create-source/run.sh b/.github/actions/create-source/run.sh new file mode 100644 index 0000000..4e3ada5 --- /dev/null +++ b/.github/actions/create-source/run.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Creates a source and warms it up. A fresh source drops what it receives in its first seconds, and every ingest +# process picks up a new token on its own schedule, so one accepted line proves nothing: the source counts as ready +# after 20 probe lines in a row were accepted, plus a short pause to settle. Probe lines carry the message +# "e2e warm-up ", which verify-logs ignores. +# Env: INPUT_API_TOKEN, INPUT_NAME, INPUT_PLATFORM, GITHUB_OUTPUT. +set -euo pipefail +# A native jq.exe, as on Windows runners, ends its output lines with CRLF unless given -b. +if [ "${OS:-}" = Windows_NT ]; then jq() { command jq -b "$@"; }; fi + +API=https://telemetry.betterstack.com + +BODY=$(jq -nc --arg name "$INPUT_NAME" --arg platform "$INPUT_PLATFORM" '{name: $name, platform: $platform}') +RES=$(curl -sS -m 60 -X POST "$API/api/v1/sources" -H "Authorization: Bearer $INPUT_API_TOKEN" -H 'Content-Type: application/json' -d "$BODY") || true +ID=$(jq -er '.data.id' <<<"$RES" 2>/dev/null) || { echo "::error::creating source $INPUT_NAME failed: ${RES:-no response}"; exit 1; } +TOKEN=$(jq -r '.data.attributes.token' <<<"$RES") +INGESTING_HOST=$(jq -r '.data.attributes.ingesting_host' <<<"$RES") +DATA_REGION=$(jq -r '.data.attributes.data_region' <<<"$RES") +echo "::add-mask::$TOKEN" +{ + echo "id=$ID" + echo "token=$TOKEN" + echo "ingesting-host=$INGESTING_HOST" + echo "data-region=$DATA_REGION" + echo "collection=t$(jq -r '.data.attributes.team_id' <<<"$RES")_$(jq -r '.data.attributes.table_name' <<<"$RES")" + echo "connect-host=$DATA_REGION-connect.betterstackdata.com" +} >> "$GITHUB_OUTPUT" +echo "created source $ID \`$INPUT_NAME\` in data region $DATA_REGION" + +PROBE=$(jq -nc --arg message "e2e warm-up $INPUT_NAME" '{message: $message}') +STREAK=0 +DEADLINE=$((SECONDS + 300)) +while [ "$STREAK" -lt 20 ]; do + CODE=$(curl -sS -m 10 -o /dev/null -w '%{http_code}' -X POST "https://$INGESTING_HOST/" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$PROBE") || true + if [ "$CODE" = 202 ]; then STREAK=$((STREAK + 1)); else echo "ingest answered HTTP $CODE after $STREAK accepted in a row"; STREAK=0; fi + [ "$SECONDS" -lt "$DEADLINE" ] || { echo "::error::ingest did not accept the token of source $ID 20 times in a row within 5 min"; exit 1; } + sleep 1 +done +sleep 15 +echo "ingest accepted 20 probe lines in a row, source $ID is ready" diff --git a/.github/actions/verify-logs/action.yml b/.github/actions/verify-logs/action.yml new file mode 100644 index 0000000..3c56876 --- /dev/null +++ b/.github/actions/verify-logs/action.yml @@ -0,0 +1,47 @@ +# Reads a source's logs back through the team's SQL API connection and checks them exactly against an expected file. +# The logic is in run.sh, which reads only environment variables, so it runs the same way outside of Actions. +name: "Verify stored logs" +description: "Polls a source's stored logs until the expected rows arrive, then checks each one is stored exactly once" + +inputs: + connection-username: + description: "Username of the team's SQL API connection" + required: true + connection-password: + description: "Password of the team's SQL API connection" + required: true + connect-host: + description: "SQL API host, the connect-host output of create-source" + required: true + collection: + description: "Collection to read, the collection output of create-source" + required: true + expected: + description: >- + Path to a JSON array of the rows that must be stored, each {"level": ..., "message": ...} or + {"level": ..., "message_prefix": ...}, optionally with "contains": an object the stored row must contain + required: true + out: + description: "Directory for rows.json and summary.md" + default: out + deadline-seconds: + description: "How long to wait for the expected number of rows" + default: "300" + ignore-message-prefix: + description: "Rows whose message starts with this are not counted (the warm-up lines of create-source)" + default: "e2e warm-up" + +runs: + using: composite + steps: + - shell: bash + env: + INPUT_CONNECTION_USERNAME: ${{ inputs.connection-username }} + INPUT_CONNECTION_PASSWORD: ${{ inputs.connection-password }} + INPUT_CONNECT_HOST: ${{ inputs.connect-host }} + INPUT_COLLECTION: ${{ inputs.collection }} + INPUT_EXPECTED: ${{ inputs.expected }} + INPUT_OUT: ${{ inputs.out }} + INPUT_DEADLINE_SECONDS: ${{ inputs.deadline-seconds }} + INPUT_IGNORE_MESSAGE_PREFIX: ${{ inputs.ignore-message-prefix }} + run: bash "${{ github.action_path }}/run.sh" diff --git a/.github/actions/verify-logs/run.sh b/.github/actions/verify-logs/run.sh new file mode 100644 index 0000000..a18cfcf --- /dev/null +++ b/.github/actions/verify-logs/run.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Reads a source's logs back through the team's SQL API connection and checks them against an expected file: as +# many rows as expected, each expected entry matched by exactly one row, no row left unmatched. Rows land in hot +# storage first and are copied to cold storage later, so both are read and a cold row counts only when hot storage +# lacks it. Once enough rows arrived it reads again 30 s later, so a duplicate delivery fails instead of slipping +# through. Writes /rows.json and /summary.md on every exit path: the summary is what a failure report shows. +# Env: INPUT_CONNECTION_USERNAME, INPUT_CONNECTION_PASSWORD, INPUT_CONNECT_HOST, INPUT_COLLECTION, INPUT_EXPECTED, +# INPUT_OUT (default out), INPUT_DEADLINE_SECONDS (default 300), INPUT_IGNORE_MESSAGE_PREFIX (default "e2e warm-up"). +set -euo pipefail +# A native jq.exe, as on Windows runners, ends its output lines with CRLF unless given -b. +if [ "${OS:-}" = Windows_NT ]; then jq() { command jq -b "$@"; }; fi + +OUT=${INPUT_OUT:-out} +IGNORE=${INPUT_IGNORE_MESSAGE_PREFIX-e2e warm-up} +# jq: the warm-up lines of create-source, rows whose message starts with the ignore prefix. +IGNORED='def ignored: $ignore != "" and (.raw.message | type) == "string" and (.raw.message | startswith($ignore));' +CHECKS="" +FAILURE="" +COUNTS="" +mkdir -p "$OUT" +echo '[]' > "$OUT/rows.json" + +fail() { FAILURE="$1"; echo "::error::$1"; exit 1; } + +summarize() { + local status=$? + { + echo "Read back through \`$INPUT_CONNECT_HOST\` from \`$INPUT_COLLECTION\`${COUNTS:+: $COUNTS}" + echo + if [ -n "$CHECKS" ]; then printf '%s' "$CHECKS"; else echo "- ❌ ${FAILURE:-stopped on an error, see the log of this step}"; fi + if [ "$(jq length "$OUT/rows.json")" -gt 0 ]; then + echo + echo "
One stored row" + echo + echo '```json' + jq --arg ignore "$IGNORE" "$IGNORED"' (map(select(ignored | not)) | max_by(.raw | keys | length)) // .[0]' "$OUT/rows.json" + echo '```' + echo + echo "
" + fi + } > "$OUT/summary.md" + exit "$status" +} +trap summarize EXIT + +jq -e 'type == "array" and length > 0 and all(.[]; type == "object" and has("level") and (has("message") or has("message_prefix")))' "$INPUT_EXPECTED" >/dev/null 2>&1 \ + || fail "$INPUT_EXPECTED is not a JSON array of {\"level\", \"message\" or \"message_prefix\", optional \"contains\"} objects" +WANT=$(jq length "$INPUT_EXPECTED") + +# Writes the rows as JSONEachRow into $2, or nothing while the collection does not exist: it is created with the +# source's first stored row. +sql() { + local code + code=$(curl -sS -m 60 -o "$2" -w '%{http_code}' -u "$INPUT_CONNECTION_USERNAME:$INPUT_CONNECTION_PASSWORD" \ + -H 'Content-Type: plain/text' --data-binary "$1 FORMAT JSONEachRow" "https://$INPUT_CONNECT_HOST/") || code=000 + if [ "$code" = 500 ] && grep -qE 'CLUSTER_DOESNT_EXIST|NAMED_COLLECTION_DOESNT_EXIST|UNKNOWN_TABLE' "$2"; then : > "$2"; return; fi + [ "$code" = 200 ] || fail "the SQL API on $INPUT_CONNECT_HOST answered HTTP $code: $(head -c 300 "$2" 2>/dev/null)" +} + +read_rows() { + sql "SELECT dt, raw FROM remote(${INPUT_COLLECTION}_logs)" "$OUT/hot.ndjson" + sql "SELECT dt, raw FROM s3Cluster(primary, ${INPUT_COLLECTION}_s3) WHERE _row_type = 1" "$OUT/cold.ndjson" + jq -n --slurpfile hot "$OUT/hot.ndjson" --slurpfile cold "$OUT/cold.ndjson" \ + '($hot | map({dt, raw})) as $h | $h + ($cold | map({dt, raw}) | map(select(IN($h[]) | not))) | map(.raw |= fromjson) | sort_by(.dt)' > "$OUT/rows.json" + local all hot + all=$(jq length "$OUT/rows.json") + hot=$(jq -s length "$OUT/hot.ndjson") + GOT=$(jq --arg ignore "$IGNORE" "$IGNORED"' map(select(ignored | not)) | length' "$OUT/rows.json") + COUNTS="$hot rows in hot storage, $((all - hot)) more in cold storage, $((all - GOT)) of them ignored as \"$IGNORE\" lines" +} + +DEADLINE=$((SECONDS + ${INPUT_DEADLINE_SECONDS:-300})) +while :; do + read_rows + [ "$GOT" -lt "$WANT" ] || break + [ "$SECONDS" -lt "$DEADLINE" ] || { echo "deadline passed with $GOT of $WANT rows"; break; } + echo "$GOT of $WANT rows so far, reading again in 10 s" + sleep 10 +done +echo "$GOT of $WANT rows, reading once more in 30 s to catch duplicate delivery" +sleep 30 +read_rows + +# One check per line: the row count, each expected entry (exactly one row matches it), each row no entry matches. +# "contains" is jq's contains: objects and arrays may hold more than expected, strings match as substrings. +RESULT=$(jq -c --arg ignore "$IGNORE" --slurpfile expected "$INPUT_EXPECTED" "$IGNORED"' + def text: tostring | gsub("\\s+"; " ") | if length > 100 then .[0:100] + "..." else . end; + def message_matches($e): (.message | type) == "string" and if $e | has("message") then .message == $e.message else .message | startswith($e.message_prefix) end; + def matches($e): .level == $e.level and message_matches($e) and (($e | has("contains") | not) or contains($e.contains)); + def describe: "`\(.level)` " + (if has("message") then "\"\(.message | text)\"" else "message starting with \"\(.message_prefix | text)\"" end) + + (if has("contains") then " containing `\(.contains | tojson | text)`" else "" end); + def near($e): [.[] | select(message_matches($e)) | if .level != $e.level then "a row with this message has level `\(.level)`" else "a row with this message lacks part of what it should contain" end] | unique; + map(select(ignored | not) | .raw) as $rows | $expected[0] as $expected + | [{ok: (($rows | length) == ($expected | length)), text: "\($rows | length) rows stored, \($expected | length) expected"}] + + [$expected[] as $e | ([$rows[] | select(matches($e))] | length) as $n | ($rows | near($e)) as $near + | {ok: ($n == 1), text: (($e | describe) + if $n == 1 then " stored once" elif $n > 1 then " stored \($n) times" + elif $near == [] then " missing" else " missing (\($near | join(", ")))" end)}] + + [$rows[] | select(. as $r | any($expected[]; . as $e | $r | matches($e)) | not) | {ok: false, text: "unexpected row: `\(.level)` \"\(.message | text)\""}] +' "$OUT/rows.json") +CHECKS=$(jq -r '.[] | "- \(if .ok then "✅" else "❌" end) \(.text)"' <<<"$RESULT")$'\n' +printf '%s' "$CHECKS" +PROBLEMS=$(jq -r 'map(select(.ok | not) | .text) | join("; ")' <<<"$RESULT") +[ -z "$PROBLEMS" ] || fail "the stored rows differ from $INPUT_EXPECTED: $PROBLEMS" +echo "every expected row is stored exactly once" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..00cb317 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,201 @@ +name: E2E + +# Proves that the client still delivers logs to Better Stack. Every job creates its own go source through the Telemetry +# API, warms it up, runs example-project (what its doc comment tells customers to run) with that source's token and +# ingesting host, reads the stored rows back through the testing team's SQL API connection, checks them exactly +# against e2e/expected.json (nothing missing, extra or duplicated), and deletes the source again, together with +# sources of earlier runs left behind for more than 3 hours. e2e/plan.sh plans three legs: +# floor the Go of go.mod's go directive, at its newest patch release, with the slog-common version go.mod requires +# latest the newest stable Go with the newest slog-common release on the Go module proxy +# canary the newest Go release candidate or beta when one is newer than the stable release (otherwise the stable +# release again, as the job name says), with slog-common's newest pre-release when there is one +# The floor leg runs on the oldest Ubuntu image GitHub offers, latest and canary on ubuntu-latest, so a new runner image +# is covered as well. +# Every job checks, before it creates a source, that the example binary was built with the planned Go, from the +# planned client (the checkout, or the release on the schedule) and with the planned slog-common, and writes them into +# its summary. +# Pushes to main and pull requests build the example as the package of this module, so it compiles the checkout's +# code. The daily schedule builds it the way a customer's own program builds: as a module of its own that requires the +# newest slog-betterstack release from the Go module proxy, which is what a customer gets today. A failure on main or +# on the schedule files a Linear issue (notify_linear_failure.yml), at low priority when only the canary failed. +# Pull requests from forks run only the plan: they get no secrets, so they cannot create a source. +# +# Secrets: TEST_TEAM_TELEMETRY_API, TEST_TEAM_CONNECTION_USERNAME, TEST_TEAM_CONNECTION_PASSWORD (Better Stack's E2E +# testing team), LINEAR_* (see notify_linear_failure.yml). +# +# One leg, or exact versions: Actions → E2E → Run workflow, pick the leg and optionally set go-version (e.g. 1.24.5 or +# 1.28rc1) or slog-common-version (e.g. v0.21.0), which replace the planned versions of every leg that runs. That is +# how a failure is bisected to one release. + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 8 * * *' + workflow_dispatch: + inputs: + leg: + description: 'Leg to run' + type: choice + options: [all, floor, latest, canary] + default: all + source: + description: 'checkout: the example built from this checkout. proxy: built against the newest release on the Go module proxy, what a customer gets' + type: choice + options: [checkout, proxy] + default: checkout + go-version: + description: 'One exact Go version (e.g. 1.24.5 or 1.28rc1) instead of the planned one' + type: string + slog-common-version: + description: 'One exact github.com/samber/slog-common version (e.g. v0.21.0) instead of the planned one' + type: string + +permissions: + contents: read + +defaults: + run: + # -eo pipefail: a failed command in front of a pipe fails the step. + shell: bash + +jobs: + plan: + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + + steps: + - name: "[Setup] Checkout code" + uses: actions/checkout@v7 + + - name: "[Setup] Plan the legs" + id: plan + env: + LEG: ${{ inputs.leg || 'all' }} + GO_VERSION: ${{ inputs.go-version }} + SLOG_COMMON_VERSION: ${{ inputs.slog-common-version }} + # The schedule tests what a customer gets today, pushes and pull requests test the checkout. + CLIENT_FROM: ${{ github.event_name == 'schedule' && 'proxy' || inputs.source || 'checkout' }} + run: | + MATRIX=$(bash e2e/plan.sh) + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + jq . <<<"$MATRIX" + + test: + needs: plan + name: ${{ matrix.name }} + # Forks and Dependabot get no repository secrets, so their pull requests run only the plan. + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.matrix) }} + + env: + LEG: ${{ matrix.leg }} + CLIENT_FROM: ${{ matrix.source }} + CLIENT: ${{ matrix.client }} + PLANNED_GO: ${{ matrix.go }} + PLANNED_SLOG_COMMON: ${{ matrix.slog_common }} + + steps: + - name: "[Setup] Checkout code" + uses: actions/checkout@v7 + + # setup-go also sets GOTOOLCHAIN=local, so go never switches to a newer Go that a go.mod asks for: it fails + # instead. check-latest takes the newest patch release of the floor's minor rather than an older one the runner + # image has cached. Without the module cache, modules come from the module proxy, as in a customer's first build. + - name: "[Setup] Go ${{ matrix.go }}" + uses: actions/setup-go@v7 + with: + go-version: ${{ matrix.setup_go }} + check-latest: true + cache: false + + # Before a source is created: an example that cannot even build with these versions needs no round trip. + - name: "[Verify] The example builds with the leg's versions" + run: bash e2e/example.sh build + + - name: "[Verify] The build is the planned one" + run: bash e2e/example.sh verify + + - name: "[Setup] Create a source" + id: source + uses: ./.github/actions/create-source + with: + api-token: ${{ secrets.TEST_TEAM_TELEMETRY_API }} + name: e2e-slog-gh${{ github.run_id }}a${{ github.run_attempt }}-${{ matrix.leg }} + platform: go + + # The binary the previous steps built and checked, which is what `go run` builds. The example closes the handler + # and exits 1 when Better Stack did not accept every record. + - name: "[Test] Run the example project" + env: + BETTER_STACK_SOURCE_TOKEN: ${{ steps.source.outputs.token }} + BETTER_STACK_INGESTING_HOST: ${{ steps.source.outputs.ingesting-host }} + run: | + if ! out/example 2>&1 | tee out/example.log; then + echo "::error::The example project failed with $INSTALLED: $(tail -n 1 out/example.log)" + exit 1 + fi + + - name: "[Verify] Read the rows back" + uses: ./.github/actions/verify-logs + with: + connection-username: ${{ secrets.TEST_TEAM_CONNECTION_USERNAME }} + connection-password: ${{ secrets.TEST_TEAM_CONNECTION_PASSWORD }} + connect-host: ${{ steps.source.outputs.connect-host }} + collection: ${{ steps.source.outputs.collection }} + expected: e2e/expected.json + + - name: "[Cleanup] Delete this job's source and stale ones of earlier runs" + if: ${{ always() }} + uses: ./.github/actions/cleanup-sources + with: + api-token: ${{ secrets.TEST_TEAM_TELEMETRY_API }} + prefix: e2e-slog- + id: ${{ steps.source.outputs.id }} + + - name: "[Report] Job summary" + if: ${{ always() }} + run: | + if [ -f out/summary.md ]; then + { echo; cat out/summary.md; } >> "$GITHUB_STEP_SUMMARY" + # The Linear issue names the job and links the run, so it gets the versions, the read-back and the + # cleanup only when that left something behind. Without a read-back summary the notifier takes the + # job's ::error:: lines instead, which name the versions. + mkdir -p report + { cat out/versions.md; echo; cat out/summary.md; } > report/summary.md + if grep -q '❌' out/cleanup.md 2>/dev/null; then { echo; cat out/cleanup.md; } >> report/summary.md; fi + else + { echo; echo "❌ Stopped before the rows were read back, see the failed step."; } >> "$GITHUB_STEP_SUMMARY" + fi + if [ -f out/cleanup.md ]; then { echo; cat out/cleanup.md; } >> "$GITHUB_STEP_SUMMARY"; fi + + - name: "[Report] Hand the summary to the Linear issue" + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: e2e-summary-${{ matrix.name }} + path: report/summary.md + if-no-files-found: ignore + retention-days: 7 + # A re-run attempt uploads under the same name. + overwrite: true + + notify-linear: + if: ${{ failure() && (github.ref == 'refs/heads/main' || github.event_name == 'schedule') }} + needs: [test] + # The notifier reads the failed jobs, their summary artifacts and their annotations. + permissions: + contents: read + actions: read + checks: read + uses: ./.github/workflows/notify_linear_failure.yml + with: + job_summaries: true + secrets: inherit diff --git a/.github/workflows/notify_linear_failure.yml b/.github/workflows/notify_linear_failure.yml new file mode 100644 index 0000000..59245f9 --- /dev/null +++ b/.github/workflows/notify_linear_failure.yml @@ -0,0 +1,153 @@ +name: "Notify Linear on failure" + +# Files a Linear issue for a failed run: "E2E failure: ()" at High priority, listing the +# failed jobs and steps with their errors, or with their summaries when job_summaries is set. When every failed job's +# name starts with "canary" (pre-release runtime or dependencies), the title says "E2E canary failure" and the +# priority is Low. While an issue with the same title is still open, the run is added to it as a comment instead, +# so a test that keeps failing every day makes one issue, not one per day. +# Secrets: LINEAR_API_KEY, LINEAR_TEAM_TELEMETRY_ID, LINEAR_STATUS_PLAN_ID, LINEAR_PROJECT_INGESTION_FOR_EVERYTHING_ID, +# LINEAR_ASSIGNEE_PETR_ID. +on: + workflow_call: + inputs: + job_summaries: + description: >- + Adds each failed job's summary to the issue, or its errors when it has none. A job hands its summary over as + an artifact named "e2e-summary-" holding summary.md. + type: boolean + default: false + +jobs: + create-linear-issue: + runs-on: ubuntu-latest + # A caller must grant checks: read as well (the errors come from the annotations), or this job cannot start. + permissions: + contents: read + actions: read + checks: read + steps: + - name: "Gather failure context" + id: ctx + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.event.workflow_run.id || github.run_id }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt || github.run_attempt }} + JOB_SUMMARIES: ${{ inputs.job_summaries }} + run: | + JOBS=$(gh api \ + "repos/${{ github.repository }}/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs" \ + --paginate \ + --jq '.jobs[] + | select(.conclusion == "failure") + | {id, name, steps: ([.steps[] | select(.conclusion == "failure") | .name] | join(", "))} + | @json') + + FAILED="" + while read -r job; do + [ -n "$job" ] || continue + ID=$(jq -r .id <<<"$job") + NAME=$(jq -r .name <<<"$job") + STEPS=$(jq -r .steps <<<"$job") + FAILED="$FAILED### $NAME"$'\n\n'"Failed at **${STEPS}**"$'\n\n' + # A job's summary already says why it failed. Otherwise, or when the job stopped before writing it, the + # issue gets its ::error:: lines, without the runner's closing "Process completed with exit code 1." + SUMMARY_DIR="summaries/$ID" + if [ "$JOB_SUMMARIES" = true ]; then + gh run download "$RUN_ID" -R "${{ github.repository }}" -n "e2e-summary-$NAME" -D "$SUMMARY_DIR" >/dev/null 2>&1 || true + fi + if [ -f "$SUMMARY_DIR/summary.md" ]; then + FAILED="$FAILED$(cat "$SUMMARY_DIR/summary.md")"$'\n\n' + else + ERRORS=$(gh api "repos/${{ github.repository }}/check-runs/$ID/annotations" \ + --jq '[.[] | select(.annotation_level == "failure" and (.message | startswith("Process completed with exit code") | not)) | .message | gsub("\n"; " ")] | unique | .[] | "- \(.)"') || ERRORS="" + FAILED="$FAILED${ERRORS:+$ERRORS$'\n\n'}" + fi + done <<<"$JOBS" + # Canary only when there are failed jobs and all of them are canary jobs. + CANARY=false + if [ -n "$JOBS" ] && jq -se 'all(.[]; .name | startswith("canary"))' <<<"$JOBS" >/dev/null; then CANARY=true; fi + + while [[ "$FAILED" == *$'\n' ]]; do FAILED="${FAILED%$'\n'}"; done + if [ -z "$FAILED" ]; then + FAILED="_(no failed jobs reported by the API yet)_" + elif [ "${#FAILED}" -gt 20000 ]; then + FAILED="${FAILED:0:20000}"$'\n\n'"_(cut at 20,000 characters, the rest is in the run)_" + fi + printf '%s\n' "$FAILED" > failed_jobs.md + echo "canary=$CANARY" >> "$GITHUB_OUTPUT" + + - name: "Create the Linear issue, or comment on the open one with the same title" + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_TELEMETRY_ID }} + LINEAR_STATUS_PLAN_ID: ${{ secrets.LINEAR_STATUS_PLAN_ID }} + LINEAR_PROJECT_INGESTION_FOR_EVERYTHING_ID: ${{ secrets.LINEAR_PROJECT_INGESTION_FOR_EVERYTHING_ID }} + LINEAR_ASSIGNEE_PETR_ID: ${{ secrets.LINEAR_ASSIGNEE_PETR_ID }} + CANARY: ${{ steps.ctx.outputs.canary }} + REPOSITORY: ${{ github.repository }} + WORKFLOW: ${{ github.event.workflow_run.name || github.workflow }} + REF_NAME: ${{ github.event.workflow_run.head_branch || github.ref_name }} + RUN_URL: ${{ github.event.workflow_run.html_url || format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt || github.run_attempt }} + EVENT_NAME: ${{ github.event.workflow_run.event || github.event_name }} + SHA: ${{ github.event.workflow_run.head_sha || github.sha }} + COMMIT_MESSAGE: ${{ github.event.workflow_run.head_commit.message || github.event.head_commit.message }} + run: | + if [ "$CANARY" = true ]; then + TITLE="E2E canary failure: $WORKFLOW ($REPOSITORY)"; PRIORITY=4 + else + TITLE="E2E failure: $WORKFLOW ($REPOSITORY)"; PRIORITY=2 + fi + + # head_commit is only populated on push events; skip the subject otherwise + COMMIT_LINE="**Commit:** \`${SHA:0:7}\`" + [ -z "$COMMIT_MESSAGE" ] || COMMIT_LINE="$COMMIT_LINE: $(printf '%s' "$COMMIT_MESSAGE" | head -n1)" + ATTEMPT_NOTE="" + [ "$RUN_ATTEMPT" = 1 ] || ATTEMPT_NOTE=" (attempt #$RUN_ATTEMPT)" + { + echo "**$WORKFLOW** failed on \`$REF_NAME\` via \`$EVENT_NAME\`$ATTEMPT_NOTE" + echo + echo "**Failing jobs:**" + echo + cat failed_jobs.md + echo + echo "$COMMIT_LINE" + echo + echo "[View failing run]($RUN_URL)" + } > description.md + + linear() { + curl -sS --fail-with-body -X POST https://api.linear.app/graphql \ + -H "Authorization: $LINEAR_API_KEY" -H 'Content-Type: application/json' -d "$1" + } + + # Open means any state but done, canceled or marked as a duplicate. Better a second issue than none: when the + # lookup fails, a new issue is created. + FIND=$(jq -nc --arg title "$TITLE" --arg teamId "$LINEAR_TEAM_ID" '{ + query: "query ($title: String!, $teamId: ID!) { issues(filter: {title: {eq: $title}, team: {id: {eq: $teamId}}, state: {type: {nin: [\"completed\", \"canceled\", \"duplicate\"]}}}, first: 1) { nodes { id url } } }", + variables: {title: $title, teamId: $teamId} + }') + FOUND=$(linear "$FIND") || { echo "::warning::looking for an open issue titled \"$TITLE\" failed: $FOUND"; FOUND='{}'; } + OPEN=$(jq -r '.data.issues.nodes[0] // empty | "\(.id) \(.url)"' <<<"$FOUND") + + if [ -n "$OPEN" ]; then + read -r ISSUE_ID ISSUE_URL <<<"$OPEN" + PAYLOAD=$(jq -nc --arg issueId "$ISSUE_ID" --rawfile body description.md '{ + query: "mutation ($input: CommentCreateInput!) { commentCreate(input: $input) { success } }", + variables: {input: {issueId: $issueId, body: $body}} + }') + RES=$(linear "$PAYLOAD") || true + jq -e '.data.commentCreate.success' <<<"$RES" >/dev/null || { echo "::error::commenting on $ISSUE_URL failed: $RES"; exit 1; } + echo "added this run to the open issue $ISSUE_URL" + else + PAYLOAD=$(jq -nc --arg title "$TITLE" --rawfile description description.md --arg teamId "$LINEAR_TEAM_ID" \ + --arg assigneeId "$LINEAR_ASSIGNEE_PETR_ID" --arg stateId "$LINEAR_STATUS_PLAN_ID" \ + --arg projectId "$LINEAR_PROJECT_INGESTION_FOR_EVERYTHING_ID" --argjson priority "$PRIORITY" '{ + query: "mutation ($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { url } } }", + variables: {input: {title: $title, description: $description, teamId: $teamId, assigneeId: $assigneeId, + stateId: $stateId, projectId: $projectId, priority: $priority}} + }') + RES=$(linear "$PAYLOAD") || true + ISSUE_URL=$(jq -er '.data.issueCreate.issue.url' <<<"$RES") || { echo "::error::creating the issue failed: $RES"; exit 1; } + echo "created $ISSUE_URL" + fi diff --git a/e2e/example.sh b/e2e/example.sh new file mode 100644 index 0000000..a677ca3 --- /dev/null +++ b/e2e/example.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Builds the example project with the leg's slog-common and proves that the build is the planned one. +# e2e/example.sh build resolves the modules and builds example-project into out/example +# e2e/example.sh verify checks the Go and the module versions compiled into out/example against the plan, prints +# them and appends them to out/versions.md +# From the checkout, the example builds as the package of this module that its doc comment tells customers to run, so +# it compiles the checkout's code. On the schedule it builds the way a customer's program does: as a module of its own, +# outside the checkout, that requires the slog-betterstack release under test from the Go module proxy. +# Env: LEG, CLIENT_FROM (checkout or proxy), CLIENT (the release under test on the schedule), PLANNED_GO (1.21, which +# also matches 1.21.13, or 1.27.3 or 1.28rc1), PLANNED_SLOG_COMMON, RUNNER_TEMP (where the schedule's module goes). +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +OUT=$ROOT/out +CLIENT_MODULE=github.com/BetterStackHQ/slog-betterstack +SLOG_COMMON=github.com/samber/slog-common +mkdir -p "$OUT" +if [ "$CLIENT_FROM" = checkout ]; then MODULE_DIR=$ROOT; else MODULE_DIR=$RUNNER_TEMP/example; fi + +case "$1" in + build) + # Every go command's output goes to out/build.log too, and a failure ends with go's own last line. + : > "$OUT/build.log" + step() { + local log status=0 + log=$("$@" 2>&1) || status=$? + [ -z "$log" ] || echo "$log" | tee -a "$OUT/build.log" + [ "$status" = 0 ] || { echo "::error::$* failed on $(go env GOVERSION): $(grep -v '^go: downloading' <<<"$log" | tail -n 1)"; exit 1; } + } + if [ "$CLIENT_FROM" = checkout ]; then + cd "$ROOT" + # A no-op on the floor, where go.mod already requires this version. + step go get "$SLOG_COMMON@$PLANNED_SLOG_COMMON" + step go build -o "$OUT/example" ./example-project + else + rm -rf "$MODULE_DIR" + mkdir -p "$MODULE_DIR" + cp "$ROOT"/example-project/*.go "$MODULE_DIR/" + cd "$MODULE_DIR" + step go mod init example + # One go get, so that go fails instead of quietly moving one module when the two versions do not fit together. + step go get "$CLIENT_MODULE@$CLIENT" "$SLOG_COMMON@$PLANNED_SLOG_COMMON" + step go build -o "$OUT/example" . + fi + ;; + + verify) + cd "$MODULE_DIR" + # go version -m reads what the binary was built from: the Go release, the main module and every module compiled + # in, one tab-separated line each ("mod" or "dep", path, version), with a "=>" line after a replaced one. + BUILD=$(go version -m "$OUT/example") + GO=$(sed -n '1s/^.*: //p' <<<"$BUILD") + compiled() { awk -F '\t' -v kind="$1" -v module="$2" '$2 == kind && $3 == module { print $4 }' <<<"$BUILD"; } + CLIENT_INFO=$(go list -m -json "$CLIENT_MODULE") + SLOG_COMMON_VERSION=$(compiled dep "$SLOG_COMMON") + if [ "$CLIENT_FROM" = checkout ]; then + FROM="from the checkout at $(git -C "$ROOT" rev-parse --short HEAD)" + else + FROM="$(compiled dep "$CLIENT_MODULE") from the module proxy" + fi + INSTALLED="$GO $(go env GOOS)/$(go env GOARCH), slog-betterstack $FROM, slog-common $SLOG_COMMON_VERSION, samber/lo $(compiled dep github.com/samber/lo)" + echo "- $INSTALLED" | tee -a "$OUT/versions.md" ${GITHUB_STEP_SUMMARY:+"$GITHUB_STEP_SUMMARY"} + # The errors of the later steps name the versions: the Linear issue shows them when no read-back summary exists. + [ -z "${GITHUB_ENV:-}" ] || echo "INSTALLED=$INSTALLED" >> "$GITHUB_ENV" + FAILED=0 + error() { echo "::error::$1"; FAILED=1; } + + case "$GO" in + "go$PLANNED_GO" | "go$PLANNED_GO".*) ;; + *) error "The example was built with $GO, but the $LEG leg plans Go $PLANNED_GO." ;; + esac + if [ "$CLIENT_FROM" = checkout ]; then + # The client is the main module, in the checkout's directory, not a release from the module proxy. + if [ "$(compiled mod "$CLIENT_MODULE")" = "" ] || [ "$(jq -r '.Main == true and .Dir == $root' --arg root "$ROOT" <<<"$CLIENT_INFO")" != true ]; then + error "The example was not built from the checkout: go list -m reports $(jq -c '{Path, Version, Main, Dir}' <<<"$CLIENT_INFO")." + fi + else + # The client is exactly the planned release, in the module cache and not replaced. + if [ "$(compiled dep "$CLIENT_MODULE")" != "$CLIENT" ] || grep -q $'^\t=>' <<<"$BUILD" \ + || [ "$(jq -r '.Version == $client and .Replace == null and (.Dir | startswith($cache))' --arg client "$CLIENT" --arg cache "$(go env GOMODCACHE)/" <<<"$CLIENT_INFO")" != true ]; then + error "The example was not built with slog-betterstack $CLIENT from the module proxy: go list -m reports $(jq -c '{Path, Version, Dir, Replace}' <<<"$CLIENT_INFO")." + fi + fi + # Every leg asks go get for one exact version, so every leg checks it, the canary too. + if [ "$SLOG_COMMON_VERSION" != "$PLANNED_SLOG_COMMON" ]; then + error "The example was built with slog-common $SLOG_COMMON_VERSION, but the $LEG leg plans $PLANNED_SLOG_COMMON." + fi + exit "$FAILED" + ;; + + *) echo "usage: e2e/example.sh build|verify" >&2; exit 1 ;; +esac diff --git a/e2e/expected.json b/e2e/expected.json new file mode 100644 index 0000000..0c017e5 --- /dev/null +++ b/e2e/expected.json @@ -0,0 +1,17 @@ +[ + { + "level": "INFO", + "message": "Starting user service.", + "contains": {"logger.name": "BetterStackHQ/slog-betterstack", "extra": {"release": "v1.0.0", "service": "UserService"}} + }, + { + "level": "WARN", + "message": "User profile is incomplete.", + "contains": {"logger.name": "BetterStackHQ/slog-betterstack", "extra": {"release": "v1.0.0", "user": {"id": "user-123", "created_at": "2024-03-01T12:00:00Z"}, "missing": ["email", "phone"]}} + }, + { + "level": "ERROR", + "message": "Unable to fetch user data.", + "contains": {"logger.name": "BetterStackHQ/slog-betterstack", "extra": {"release": "v1.0.0", "userID": 123, "error": {"kind": "*fmt.wrapError", "error": "fetching user user-123: context deadline exceeded"}}} + } +] diff --git a/e2e/plan.sh b/e2e/plan.sh new file mode 100644 index 0000000..1e22603 --- /dev/null +++ b/e2e/plan.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Plans the E2E matrix: one job per leg, with the Go and the slog-common version the leg tests. Prints the matrix as a +# JSON array, the include list of the test job in e2e.yml. +# floor the Go of go.mod's go directive (setup-go installs its newest patch release) and the slog-common version +# go.mod requires +# latest the newest stable Go release on go.dev and the newest slog-common release on the Go module proxy +# canary the newest Go release candidate or beta when one is newer than that stable release, otherwise the stable +# release again, and slog-common's newest pre-release when one is above its newest release +# Pushes and pull requests test the checkout. On the schedule (CLIENT_FROM=proxy) the client is the newest +# slog-betterstack release on the module proxy, and the floor comes from that release's go.mod instead. +# Env: LEG (all, floor, latest or canary), CLIENT_FROM (checkout or proxy), and GO_VERSION (e.g. 1.24.5 or 1.28rc1) +# and SLOG_COMMON_VERSION (e.g. v0.21.0): exact versions that replace the planned ones in every leg. +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PROXY=https://proxy.golang.org +CLIENT_MODULE=github.com/BetterStackHQ/slog-betterstack +SLOG_COMMON=github.com/samber/slog-common +RELEASE='^v[0-9]+\.[0-9]+\.[0-9]+$' + +# A lookup that fails at schedule time because of a hiccup should not file a Linear issue, so curl retries. +get() { curl -fsS -m 60 --retry 3 --retry-delay 10 "$1"; } + +# The module proxy spells capital letters in a module path as "!" and the lower-case letter. +proxy_url() { echo "$PROXY/$(jq -Rr 'gsub("(?[A-Z])"; "!" + (.c | ascii_downcase))' <<<"$1")/$2"; } + +# The go directive and the version of the required module $2 in the go.mod on stdin. +go_directive() { sed -n 's/^go \([0-9][0-9.]*\)$/\1/p'; } +required() { awk -v module="$1" '($1 == module && $2 ~ /^v/) || ($1 == "require" && $2 == module) { print ($1 == "require" ? $3 : $2) }'; } + +# Whether version $1 sorts above version $2. +newer() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1)" = "$1" ]; } + +# The newest pre-release of module $1 above its newest release $2, or that release when there is none. v0.23.0-rc.1 +# is a pre-release of v0.23.0: it counts when v0.23.0 is above the newest release. +newest_prerelease() { + local versions candidate pick="" + versions=$(get "$(proxy_url "$1" @v/list)") || return 1 + while read -r candidate; do + [ -n "$candidate" ] || continue + if newer "${candidate%%-*}" "$2" && { [ -z "$pick" ] || newer "$candidate" "$pick"; }; then pick=$candidate; fi + done < <(grep -vE "$RELEASE" <<<"$versions" || true) + echo "${pick:-$2}" +} + +# setup-go takes a pre-release in semver form: 1.28rc1 is 1.28.0-rc.1. +setup_go_version() { sed -E 's/^([0-9]+\.[0-9]+)(beta|rc)([0-9]+)$/\1.0-\2.\3/' <<<"$1"; } + +# The go.mod the floor comes from: the checkout's, or on the schedule the one of the release under test. +if [ "${CLIENT_FROM:-checkout}" = proxy ]; then + CLIENT=$(get "$(proxy_url "$CLIENT_MODULE" @latest)" | jq -r .Version) + GO_MOD=$(get "$(proxy_url "$CLIENT_MODULE" "@v/$CLIENT.mod")") +else + CLIENT=checkout + GO_MOD=$(cat "$ROOT/go.mod") +fi + +LEGS=$([ "${LEG:-all}" = all ] && echo "floor latest canary" || echo "$LEG") +STABLE_GO="" +NEWEST_SLOG_COMMON="" +MATRIX='[]' +for leg in $LEGS; do + if [ "$leg" != floor ] && [ -z "$STABLE_GO" ]; then + STABLE_GO=$(get 'https://go.dev/dl/?mode=json' | jq -r '[.[] | select(.stable) | .version | ltrimstr("go")] | max_by(split(".") | map(tonumber))') + NEWEST_SLOG_COMMON=$(get "$(proxy_url "$SLOG_COMMON" @latest)" | jq -r .Version) + fi + note="" + case "$leg" in + floor) + go=$(go_directive <<<"$GO_MOD") + slog_common=$(required "$SLOG_COMMON" <<<"$GO_MOD") ;; + latest) + go=$STABLE_GO + slog_common=$NEWEST_SLOG_COMMON ;; + canary) + # Go publishes betas and release candidates only for the next minor, as go1.28rc1 or go1.28beta1. + go=$(get 'https://go.dev/dl/?mode=json&include=all' | jq -r --arg stable "$STABLE_GO" ' + ($stable | split(".") | map(tonumber)) as $s + | [.[] | select(.stable | not) | .version | ltrimstr("go") + | capture("^(?[0-9]+)\\.(?[0-9]+)(?beta|rc)(?[0-9]+)$") as $v + | {version: ., key: [($v.major | tonumber), ($v.minor | tonumber), (if $v.kind == "rc" then 1 else 0 end), ($v.n | tonumber)]} + | select(.key[0:2] > $s[0:2])] + | max_by(.key).version // empty') + if [ -z "$go" ]; then go=$STABLE_GO; note=" as no pre-release is newer"; fi + slog_common=$(newest_prerelease "$SLOG_COMMON" "$NEWEST_SLOG_COMMON") ;; + *) echo "unknown leg $leg" >&2; exit 1 ;; + esac + if [ -n "${GO_VERSION:-}" ]; then go=$GO_VERSION; note=""; fi + slog_common=${SLOG_COMMON_VERSION:-$slog_common} + for value in "$go" "$slog_common" "$CLIENT"; do + [ -n "$value" ] && [ "$value" != null ] || { echo "the $leg leg could not be planned: go '$go', slog-common '$slog_common', client '$CLIENT'" >&2; exit 1; } + done + MATRIX=$(jq -c --arg leg "$leg" --arg go "$go" --arg setup_go "$(setup_go_version "$go")" --arg note "$note" \ + --arg slog_common "$slog_common" --arg source "${CLIENT_FROM:-checkout}" --arg client "$CLIENT" '. + [{ + name: "\($leg) (Go \($go)\($note), slog-common \($slog_common))", + leg: $leg, + # go is what `go version` must name (1.21 also matches 1.21.13), setup_go what actions/setup-go installs. + go: $go, setup_go: $setup_go, + slog_common: $slog_common, source: $source, client: $client, + # The floor runs on the oldest Ubuntu image GitHub offers, the other legs follow ubuntu-latest. + runner: (if $leg == "floor" then "ubuntu-24.04" else "ubuntu-latest" end) + }]' <<<"$MATRIX") +done +# Job names become artifact names (e2e-summary-), which must not contain any of these. +if jq -e 'any(.[].name; test("[\":<>|*?\\\\/]"))' <<<"$MATRIX" >/dev/null; then + echo "a job name contains a character artifact names do not allow: $(jq -r '.[].name' <<<"$MATRIX")" >&2 + exit 1 +fi +echo "$MATRIX" diff --git a/example-project/main.go b/example-project/main.go index f5a315d..bc6c4d4 100644 --- a/example-project/main.go +++ b/example-project/main.go @@ -3,10 +3,11 @@ // Run it with the source token and the ingesting host of your source, both shown in // Better Stack → Sources: // -// BETTERSTACK_SOURCE_TOKEN=... BETTERSTACK_INGESTING_HOST=... go run ./example-project +// BETTER_STACK_SOURCE_TOKEN=... BETTER_STACK_INGESTING_HOST=... go run ./example-project package main import ( + "context" "fmt" "log/slog" "os" @@ -16,37 +17,35 @@ import ( ) func main() { - token := os.Getenv("BETTERSTACK_SOURCE_TOKEN") - if token == "" { - fmt.Fprintln(os.Stderr, "set BETTERSTACK_SOURCE_TOKEN to the source token from Better Stack → Sources") + token := os.Getenv("BETTER_STACK_SOURCE_TOKEN") + host := os.Getenv("BETTER_STACK_INGESTING_HOST") + if token == "" || host == "" { + fmt.Fprintln(os.Stderr, "usage: BETTER_STACK_SOURCE_TOKEN= BETTER_STACK_INGESTING_HOST= go run ./example-project") os.Exit(1) } - option := slogbetterstack.Option{ - Token: token, - Level: slog.LevelInfo, // Debug if omitted - } - if host := os.Getenv("BETTERSTACK_INGESTING_HOST"); host != "" { - option.Endpoint = "https://" + host + "/" - } - - handler := option.NewBetterstackHandler() - logger := slog.New(handler) - logger = logger.With("release", "v1.0.0") + handler := slogbetterstack.Option{ + Token: token, + Endpoint: "https://" + host + "/", + Level: slog.LevelInfo, // Debug if omitted + }.NewBetterstackHandler() + logger := slog.New(handler).With("release", "v1.0.0") logger.Info("Starting user service.", "service", "UserService") - logger.With("userID", 123).Error("Unable to fetch user data.") + // Below the handler's Level, so it is not sent. + logger.Debug("Loading the user cache.") - logger. - With( - slog.Group("user", - slog.String("id", "user-123"), - slog.Time("created_at", time.Now()), - ), - ). - With("error", fmt.Errorf("an error")). - Error("a message", slog.Int("count", 1)) + logger.Warn("User profile is incomplete.", + slog.Group("user", + slog.String("id", "user-123"), + slog.Time("created_at", time.Date(2024, time.March, 1, 12, 0, 0, 0, time.UTC)), + ), + slog.Any("missing", []string{"email", "phone"}), + ) + + err := fmt.Errorf("fetching user user-123: %w", context.DeadlineExceeded) + logger.With("userID", 123).Error("Unable to fetch user data.", "error", err) // Records are sent in batches: Close delivers what is still queued before the program exits. if err := handler.Close(); err != nil { @@ -54,6 +53,11 @@ func main() { os.Exit(1) } + // A record Better Stack rejected is reported on stderr when it happens and counted in Stats. stats := handler.Stats() + if stats.Sent != stats.Enqueued { + fmt.Fprintf(os.Stderr, "Only %d of %d log records were delivered.\n", stats.Sent, stats.Enqueued) + os.Exit(1) + } fmt.Printf("Sent %d log records. Open Better Stack → Live tail to see them.\n", stats.Sent) }