logtail-rails 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c5828f2141c9b70b0f47591b7dcf8855a648c0297477637fd7029cfd5e39a8d
4
- data.tar.gz: 0a88a29844cdbbc76a20a827f0575fc3f3aa4befe32dde404315bb55a733cfa2
3
+ metadata.gz: 5ce3c0d5b067b69401f94dd38e671d089ec35acdc64fcf02aff7b77360c7f268
4
+ data.tar.gz: 134e79823c37bc41fea97a49946cc964b58c19729261ceffc137a05ff837987c
5
5
  SHA512:
6
- metadata.gz: 9238c31e5e7945f99fdc3acae8a7006f6f0568e3e2269469a0498da0d5010718d2a3dc884bfd64cc471be0c7a54283e32ff327f8fa1dd11d70b76d1baf84dc2c
7
- data.tar.gz: 76f5185abcdc9efafbe564d65a543cb924dcfb75326686645f346a7e30201b053cad5380701f8abe3d0ed01db85f0a54b631522dca449bf4aba2164967f6d840
6
+ metadata.gz: 922aa5b53d2217e240d44b663c7a5993e45186bd94a1bbaa24892c23dba05fb81efe55a6fd0e24709417cb5192d73a373a7cfb1210b714da206b8d74836e76f3
7
+ data.tar.gz: d96272725a31d29a68b9d859e621098ef0066c7e89328f445bb9970eaa91adafb3bc4fefa67d11fa0680cbabc3db241631b441e569fee35b4e66673a4a7e0ba1
@@ -0,0 +1,33 @@
1
+ # Deletes the run's source and stale sources of earlier runs of the same test, and never fails the job.
2
+ # The logic is in run.sh, which reads only environment variables, so it runs the same way outside of Actions.
3
+ name: "Clean up Better Stack sources"
4
+ description: "Deletes this run's source and sources of earlier runs, named with the same prefix, that are older than stale-hours"
5
+
6
+ inputs:
7
+ api-token:
8
+ description: "Telemetry API token of the team the sources are in"
9
+ required: true
10
+ prefix:
11
+ description: "Name prefix of this test's sources, only those are swept"
12
+ required: true
13
+ id:
14
+ description: "Id of this run's source, deleted whatever its age"
15
+ default: ""
16
+ stale-hours:
17
+ description: "Sources with the prefix older than this many hours are left over from earlier runs"
18
+ default: "3"
19
+ out:
20
+ description: "Directory for cleanup.md"
21
+ default: out
22
+
23
+ runs:
24
+ using: composite
25
+ steps:
26
+ - shell: bash
27
+ env:
28
+ INPUT_API_TOKEN: ${{ inputs.api-token }}
29
+ INPUT_PREFIX: ${{ inputs.prefix }}
30
+ INPUT_ID: ${{ inputs.id }}
31
+ INPUT_STALE_HOURS: ${{ inputs.stale-hours }}
32
+ INPUT_OUT: ${{ inputs.out }}
33
+ run: bash "${{ github.action_path }}/run.sh"
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # Deletes this run's source, then what earlier runs of the same test left behind (a cancelled job never reaches its
3
+ # cleanup): every source whose name starts with the prefix and that is older than stale-hours. Younger sources may
4
+ # belong to a run still in progress and stay. Writes <out>/cleanup.md, one line per deletion, and always exits 0:
5
+ # a failed deletion shows in the report, but never hides the test result.
6
+ # Env: INPUT_API_TOKEN, INPUT_PREFIX, INPUT_ID (optional), INPUT_STALE_HOURS (default 3), INPUT_OUT (default out).
7
+ set -euo pipefail
8
+ # A native jq.exe, as on Windows runners, ends its output lines with CRLF unless given -b.
9
+ if [ "${OS:-}" = Windows_NT ]; then jq() { command jq -b "$@"; }; fi
10
+
11
+ API=https://telemetry.betterstack.com
12
+ OUT=${INPUT_OUT:-out}
13
+ mkdir -p "$OUT"
14
+ : > "$OUT/cleanup.md"
15
+ say() { echo "$1"; echo "$1" >> "$OUT/cleanup.md"; }
16
+ trap 'status=$?; [ "$status" = 0 ] || say "- ❌ cleanup stopped on an error, see the log of this step"; exit 0' EXIT
17
+
18
+ delete() {
19
+ local code
20
+ 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
21
+ case "$code" in
22
+ 204) say "- deleted $2" ;;
23
+ 404) say "- $2 was already gone" ;;
24
+ *) say "- ❌ could not delete $2 (HTTP $code)" ;;
25
+ esac
26
+ }
27
+
28
+ say "### Cleanup"
29
+ say ""
30
+ [ -z "${INPUT_ID:-}" ] || delete "$INPUT_ID" "this run's source $INPUT_ID"
31
+
32
+ # An empty prefix would sweep every source of the team.
33
+ [ -n "$INPUT_PREFIX" ] || { say "- ❌ no prefix given, so stale sources were not looked for"; exit 0; }
34
+ # The name filter matches anywhere in the name, so the prefix is checked again. The next-page link the API returns
35
+ # drops the filter, so pages are counted here instead of following it.
36
+ STALE=""
37
+ for ((page = 1; ; page++)); do
38
+ 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=""
39
+ 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; }
40
+ STALE+=$(jq -r --arg prefix "$INPUT_PREFIX" --arg id "${INPUT_ID:-}" --argjson hours "${INPUT_STALE_HOURS:-3}" '.data[]
41
+ | select((.attributes.name | startswith($prefix)) and .id != $id)
42
+ | select((.attributes.created_at | sub("\\.[0-9]+"; "") | fromdateiso8601) < now - $hours * 3600)
43
+ | "\(.id) \(.attributes.created_at) \(.attributes.name)"' <<<"$RES")$'\n'
44
+ [ "$(jq -r '.pagination.next' <<<"$RES")" != null ] || break
45
+ done
46
+ while read -r id created_at name; do
47
+ [ -n "$id" ] || continue
48
+ delete "$id" "stale source $id \`$name\` (created $created_at)"
49
+ done <<<"$STALE"
50
+
51
+ [ "$(wc -l < "$OUT/cleanup.md")" -gt 2 ] || say "- nothing to delete"
@@ -0,0 +1,46 @@
1
+ # Creates a Better Stack source through the Telemetry API and waits until ingest reliably accepts its token.
2
+ # The logic is in run.sh, which reads only environment variables, so it runs the same way outside of Actions.
3
+ name: "Create a Better Stack source"
4
+ description: "Creates a source, warms it up until ingest accepts its token and outputs where to send and read logs"
5
+
6
+ inputs:
7
+ api-token:
8
+ description: "Telemetry API token of the team the source is created in"
9
+ required: true
10
+ name:
11
+ description: "Name of the new source"
12
+ required: true
13
+ platform:
14
+ description: "Platform of the new source, as the Telemetry API names it"
15
+ required: true
16
+
17
+ outputs:
18
+ id:
19
+ description: "Source id"
20
+ value: ${{ steps.run.outputs.id }}
21
+ token:
22
+ description: "Source token for ingest (masked in the logs)"
23
+ value: ${{ steps.run.outputs.token }}
24
+ ingesting-host:
25
+ description: "Host to send logs to"
26
+ value: ${{ steps.run.outputs.ingesting-host }}
27
+ data-region:
28
+ description: "Data region the source stores its logs in"
29
+ value: ${{ steps.run.outputs.data-region }}
30
+ collection:
31
+ description: "Collection name for the SQL API, t<team id>_<table name>"
32
+ value: ${{ steps.run.outputs.collection }}
33
+ connect-host:
34
+ description: "SQL API host that reads this source"
35
+ value: ${{ steps.run.outputs.connect-host }}
36
+
37
+ runs:
38
+ using: composite
39
+ steps:
40
+ - id: run
41
+ shell: bash
42
+ env:
43
+ INPUT_API_TOKEN: ${{ inputs.api-token }}
44
+ INPUT_NAME: ${{ inputs.name }}
45
+ INPUT_PLATFORM: ${{ inputs.platform }}
46
+ run: bash "${{ github.action_path }}/run.sh"
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # Creates a source and warms it up. A fresh source drops what it receives in its first seconds, and every ingest
3
+ # process picks up a new token on its own schedule, so one accepted line proves nothing: the source counts as ready
4
+ # after 20 probe lines in a row were accepted, plus a short pause to settle. Probe lines carry the message
5
+ # "e2e warm-up <source name>", which verify-logs ignores.
6
+ # Env: INPUT_API_TOKEN, INPUT_NAME, INPUT_PLATFORM, GITHUB_OUTPUT.
7
+ set -euo pipefail
8
+ # A native jq.exe, as on Windows runners, ends its output lines with CRLF unless given -b.
9
+ if [ "${OS:-}" = Windows_NT ]; then jq() { command jq -b "$@"; }; fi
10
+
11
+ API=https://telemetry.betterstack.com
12
+
13
+ BODY=$(jq -nc --arg name "$INPUT_NAME" --arg platform "$INPUT_PLATFORM" '{name: $name, platform: $platform}')
14
+ 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
15
+ ID=$(jq -er '.data.id' <<<"$RES" 2>/dev/null) || { echo "::error::creating source $INPUT_NAME failed: ${RES:-no response}"; exit 1; }
16
+ TOKEN=$(jq -r '.data.attributes.token' <<<"$RES")
17
+ INGESTING_HOST=$(jq -r '.data.attributes.ingesting_host' <<<"$RES")
18
+ DATA_REGION=$(jq -r '.data.attributes.data_region' <<<"$RES")
19
+ echo "::add-mask::$TOKEN"
20
+ {
21
+ echo "id=$ID"
22
+ echo "token=$TOKEN"
23
+ echo "ingesting-host=$INGESTING_HOST"
24
+ echo "data-region=$DATA_REGION"
25
+ echo "collection=t$(jq -r '.data.attributes.team_id' <<<"$RES")_$(jq -r '.data.attributes.table_name' <<<"$RES")"
26
+ echo "connect-host=$DATA_REGION-connect.betterstackdata.com"
27
+ } >> "$GITHUB_OUTPUT"
28
+ echo "created source $ID \`$INPUT_NAME\` in data region $DATA_REGION"
29
+
30
+ PROBE=$(jq -nc --arg message "e2e warm-up $INPUT_NAME" '{message: $message}')
31
+ STREAK=0
32
+ DEADLINE=$((SECONDS + 300))
33
+ while [ "$STREAK" -lt 20 ]; do
34
+ 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
35
+ if [ "$CODE" = 202 ]; then STREAK=$((STREAK + 1)); else echo "ingest answered HTTP $CODE after $STREAK accepted in a row"; STREAK=0; fi
36
+ [ "$SECONDS" -lt "$DEADLINE" ] || { echo "::error::ingest did not accept the token of source $ID 20 times in a row within 5 min"; exit 1; }
37
+ sleep 1
38
+ done
39
+ sleep 15
40
+ echo "ingest accepted 20 probe lines in a row, source $ID is ready"
@@ -0,0 +1,47 @@
1
+ # Reads a source's logs back through the team's SQL API connection and checks them exactly against an expected file.
2
+ # The logic is in run.sh, which reads only environment variables, so it runs the same way outside of Actions.
3
+ name: "Verify stored logs"
4
+ description: "Polls a source's stored logs until the expected rows arrive, then checks each one is stored exactly once"
5
+
6
+ inputs:
7
+ connection-username:
8
+ description: "Username of the team's SQL API connection"
9
+ required: true
10
+ connection-password:
11
+ description: "Password of the team's SQL API connection"
12
+ required: true
13
+ connect-host:
14
+ description: "SQL API host, the connect-host output of create-source"
15
+ required: true
16
+ collection:
17
+ description: "Collection to read, the collection output of create-source"
18
+ required: true
19
+ expected:
20
+ description: >-
21
+ Path to a JSON array of the rows that must be stored, each {"level": ..., "message": ...} or
22
+ {"level": ..., "message_prefix": ...}, optionally with "contains": an object the stored row must contain
23
+ required: true
24
+ out:
25
+ description: "Directory for rows.json and summary.md"
26
+ default: out
27
+ deadline-seconds:
28
+ description: "How long to wait for the expected number of rows"
29
+ default: "300"
30
+ ignore-message-prefix:
31
+ description: "Rows whose message starts with this are not counted (the warm-up lines of create-source)"
32
+ default: "e2e warm-up"
33
+
34
+ runs:
35
+ using: composite
36
+ steps:
37
+ - shell: bash
38
+ env:
39
+ INPUT_CONNECTION_USERNAME: ${{ inputs.connection-username }}
40
+ INPUT_CONNECTION_PASSWORD: ${{ inputs.connection-password }}
41
+ INPUT_CONNECT_HOST: ${{ inputs.connect-host }}
42
+ INPUT_COLLECTION: ${{ inputs.collection }}
43
+ INPUT_EXPECTED: ${{ inputs.expected }}
44
+ INPUT_OUT: ${{ inputs.out }}
45
+ INPUT_DEADLINE_SECONDS: ${{ inputs.deadline-seconds }}
46
+ INPUT_IGNORE_MESSAGE_PREFIX: ${{ inputs.ignore-message-prefix }}
47
+ run: bash "${{ github.action_path }}/run.sh"
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bash
2
+ # Reads a source's logs back through the team's SQL API connection and checks them against an expected file: as
3
+ # many rows as expected, each expected entry matched by exactly one row, no row left unmatched. Rows land in hot
4
+ # storage first and are copied to cold storage later, so both are read and a cold row counts only when hot storage
5
+ # lacks it. Once enough rows arrived it reads again 30 s later, so a duplicate delivery fails instead of slipping
6
+ # through. Writes <out>/rows.json and <out>/summary.md on every exit path: the summary is what a failure report shows.
7
+ # Env: INPUT_CONNECTION_USERNAME, INPUT_CONNECTION_PASSWORD, INPUT_CONNECT_HOST, INPUT_COLLECTION, INPUT_EXPECTED,
8
+ # INPUT_OUT (default out), INPUT_DEADLINE_SECONDS (default 300), INPUT_IGNORE_MESSAGE_PREFIX (default "e2e warm-up").
9
+ set -euo pipefail
10
+ # A native jq.exe, as on Windows runners, ends its output lines with CRLF unless given -b.
11
+ if [ "${OS:-}" = Windows_NT ]; then jq() { command jq -b "$@"; }; fi
12
+
13
+ OUT=${INPUT_OUT:-out}
14
+ IGNORE=${INPUT_IGNORE_MESSAGE_PREFIX-e2e warm-up}
15
+ # jq: the warm-up lines of create-source, rows whose message starts with the ignore prefix.
16
+ IGNORED='def ignored: $ignore != "" and (.raw.message | type) == "string" and (.raw.message | startswith($ignore));'
17
+ CHECKS=""
18
+ FAILURE=""
19
+ COUNTS=""
20
+ mkdir -p "$OUT"
21
+ echo '[]' > "$OUT/rows.json"
22
+
23
+ fail() { FAILURE="$1"; echo "::error::$1"; exit 1; }
24
+
25
+ summarize() {
26
+ local status=$?
27
+ {
28
+ echo "Read back through \`$INPUT_CONNECT_HOST\` from \`$INPUT_COLLECTION\`${COUNTS:+: $COUNTS}"
29
+ echo
30
+ if [ -n "$CHECKS" ]; then printf '%s' "$CHECKS"; else echo "- ❌ ${FAILURE:-stopped on an error, see the log of this step}"; fi
31
+ if [ "$(jq length "$OUT/rows.json")" -gt 0 ]; then
32
+ echo
33
+ echo "<details><summary>One stored row</summary>"
34
+ echo
35
+ echo '```json'
36
+ jq --arg ignore "$IGNORE" "$IGNORED"' (map(select(ignored | not)) | max_by(.raw | keys | length)) // .[0]' "$OUT/rows.json"
37
+ echo '```'
38
+ echo
39
+ echo "</details>"
40
+ fi
41
+ } > "$OUT/summary.md"
42
+ exit "$status"
43
+ }
44
+ trap summarize EXIT
45
+
46
+ 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 \
47
+ || fail "$INPUT_EXPECTED is not a JSON array of {\"level\", \"message\" or \"message_prefix\", optional \"contains\"} objects"
48
+ WANT=$(jq length "$INPUT_EXPECTED")
49
+
50
+ # Writes the rows as JSONEachRow into $2, or nothing while the collection does not exist: it is created with the
51
+ # source's first stored row.
52
+ sql() {
53
+ local code
54
+ code=$(curl -sS -m 60 -o "$2" -w '%{http_code}' -u "$INPUT_CONNECTION_USERNAME:$INPUT_CONNECTION_PASSWORD" \
55
+ -H 'Content-Type: plain/text' --data-binary "$1 FORMAT JSONEachRow" "https://$INPUT_CONNECT_HOST/") || code=000
56
+ if [ "$code" = 500 ] && grep -qE 'CLUSTER_DOESNT_EXIST|NAMED_COLLECTION_DOESNT_EXIST|UNKNOWN_TABLE' "$2"; then : > "$2"; return; fi
57
+ [ "$code" = 200 ] || fail "the SQL API on $INPUT_CONNECT_HOST answered HTTP $code: $(head -c 300 "$2" 2>/dev/null)"
58
+ }
59
+
60
+ read_rows() {
61
+ sql "SELECT dt, raw FROM remote(${INPUT_COLLECTION}_logs)" "$OUT/hot.ndjson"
62
+ sql "SELECT dt, raw FROM s3Cluster(primary, ${INPUT_COLLECTION}_s3) WHERE _row_type = 1" "$OUT/cold.ndjson"
63
+ jq -n --slurpfile hot "$OUT/hot.ndjson" --slurpfile cold "$OUT/cold.ndjson" \
64
+ '($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"
65
+ local all hot
66
+ all=$(jq length "$OUT/rows.json")
67
+ hot=$(jq -s length "$OUT/hot.ndjson")
68
+ GOT=$(jq --arg ignore "$IGNORE" "$IGNORED"' map(select(ignored | not)) | length' "$OUT/rows.json")
69
+ COUNTS="$hot rows in hot storage, $((all - hot)) more in cold storage, $((all - GOT)) of them ignored as \"$IGNORE\" lines"
70
+ }
71
+
72
+ DEADLINE=$((SECONDS + ${INPUT_DEADLINE_SECONDS:-300}))
73
+ while :; do
74
+ read_rows
75
+ [ "$GOT" -lt "$WANT" ] || break
76
+ [ "$SECONDS" -lt "$DEADLINE" ] || { echo "deadline passed with $GOT of $WANT rows"; break; }
77
+ echo "$GOT of $WANT rows so far, reading again in 10 s"
78
+ sleep 10
79
+ done
80
+ echo "$GOT of $WANT rows, reading once more in 30 s to catch duplicate delivery"
81
+ sleep 30
82
+ read_rows
83
+
84
+ # One check per line: the row count, each expected entry (exactly one row matches it), each row no entry matches.
85
+ # "contains" is jq's contains: objects and arrays may hold more than expected, strings match as substrings.
86
+ RESULT=$(jq -c --arg ignore "$IGNORE" --slurpfile expected "$INPUT_EXPECTED" "$IGNORED"'
87
+ def text: tostring | gsub("\\s+"; " ") | if length > 100 then .[0:100] + "..." else . end;
88
+ def message_matches($e): (.message | type) == "string" and if $e | has("message") then .message == $e.message else .message | startswith($e.message_prefix) end;
89
+ def matches($e): .level == $e.level and message_matches($e) and (($e | has("contains") | not) or contains($e.contains));
90
+ def describe: "`\(.level)` " + (if has("message") then "\"\(.message | text)\"" else "message starting with \"\(.message_prefix | text)\"" end)
91
+ + (if has("contains") then " containing `\(.contains | tojson | text)`" else "" end);
92
+ 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;
93
+ map(select(ignored | not) | .raw) as $rows | $expected[0] as $expected
94
+ | [{ok: (($rows | length) == ($expected | length)), text: "\($rows | length) rows stored, \($expected | length) expected"}]
95
+ + [$expected[] as $e | ([$rows[] | select(matches($e))] | length) as $n | ($rows | near($e)) as $near
96
+ | {ok: ($n == 1), text: (($e | describe) + if $n == 1 then " stored once" elif $n > 1 then " stored \($n) times"
97
+ elif $near == [] then " missing" else " missing (\($near | join(", ")))" end)}]
98
+ + [$rows[] | select(. as $r | any($expected[]; . as $e | $r | matches($e)) | not) | {ok: false, text: "unexpected row: `\(.level)` \"\(.message | text)\""}]
99
+ ' "$OUT/rows.json")
100
+ CHECKS=$(jq -r '.[] | "- \(if .ok then "✅" else "❌" end) \(.text)"' <<<"$RESULT")$'\n'
101
+ printf '%s' "$CHECKS"
102
+ PROBLEMS=$(jq -r 'map(select(.ok | not) | .text) | join("; ")' <<<"$RESULT")
103
+ [ -z "$PROBLEMS" ] || fail "the stored rows differ from $INPUT_EXPECTED: $PROBLEMS"
104
+ echo "every expected row is stored exactly once"
@@ -0,0 +1,275 @@
1
+ name: E2E
2
+
3
+ # Proves that the gem still delivers a Rails app's logs to Better Stack. Every job creates its own ruby source through
4
+ # the Telemetry API, warms it up, starts the example project (example-project/, a Rails app) with that source's token
5
+ # and ingesting host the way its README tells customers to (bin/rails server), requests /send_logs and /raise_error,
6
+ # stops the server with Ctrl-C, reads the stored rows back through the testing team's SQL API connection, checks them
7
+ # exactly against e2e/expected.json (nothing missing, extra or duplicated), and deletes the source again, together with
8
+ # sources of earlier runs left behind for more than 3 hours. e2e/plan.sh plans four legs:
9
+ # floor the oldest Rails the example runs on (its config.load_defaults, 7.1) at its newest patch, on the oldest Ruby
10
+ # that release allows, with logtail and logtail-rack at logtail-rails.gemspec's floors. The gemspec allows
11
+ # Rails 5.0 and Ruby 2.5, which only the unit tests (main.yml) cover.
12
+ # rails7 the newest Rails 7 on Ruby 3.2, with the newest logtail and logtail-rack
13
+ # latest the newest stable Ruby that ruby/setup-ruby can install, with the newest Rails, logtail and logtail-rack
14
+ # releases; Bundler failing to install exactly those fails the job
15
+ # canary ruby-head with the current commit of Rails' main branch
16
+ # The floor and the Rails 7 leg run on the oldest Ubuntu image GitHub offers, latest and canary on ubuntu-latest, so a
17
+ # new runner image is covered as well.
18
+ # Every job checks, before it creates a source, that the Ruby running, the client (the gem built here, or rubygems.org
19
+ # on the schedule), Rails, logtail and logtail-rack are the planned ones, and writes them into its summary. Which rows
20
+ # the example logs depends on the Rails version: e2e/expected.json marks the ones a newer Rails adds with since_rails.
21
+ # Pushes to main and pull requests install the gem built from the checkout. The daily schedule installs the newest
22
+ # release from rubygems.org, which is what a customer gets today. A failure on main or on the schedule files a Linear
23
+ # issue (notify_linear_failure.yml), at low priority when only the canary failed.
24
+ # Pull requests from forks run only the plan: they get no secrets, so they cannot create a source.
25
+ #
26
+ # Secrets: TEST_TEAM_TELEMETRY_API, TEST_TEAM_CONNECTION_USERNAME, TEST_TEAM_CONNECTION_PASSWORD (Better Stack's E2E
27
+ # testing team), LINEAR_* (see notify_linear_failure.yml).
28
+ #
29
+ # One leg, or exact versions: Actions → E2E → Run workflow, pick the leg and optionally set ruby-version (e.g. 3.3.6 or
30
+ # head) or rails-version (a release such as 8.0.3, or a commit of Rails' main branch such as 3ba9e53), which replace
31
+ # the planned versions of every leg that runs. That is how a failure is bisected to one release or commit.
32
+
33
+ on:
34
+ push:
35
+ branches: [main]
36
+ pull_request:
37
+ schedule:
38
+ - cron: '15 7 * * *'
39
+ workflow_dispatch:
40
+ inputs:
41
+ leg:
42
+ description: 'Leg to run'
43
+ type: choice
44
+ options: [all, floor, rails7, latest, canary]
45
+ default: all
46
+ source:
47
+ description: 'checkout: the gem built from this checkout, as the Release workflow builds it. rubygems: its newest release on rubygems.org, what a customer installs'
48
+ type: choice
49
+ options: [checkout, rubygems]
50
+ default: checkout
51
+ ruby-version:
52
+ description: 'One exact Ruby version (e.g. 3.3.6 or head) instead of the planned one'
53
+ type: string
54
+ rails-version:
55
+ description: 'One exact Rails release (e.g. 8.0.3) or commit of its main branch (e.g. 3ba9e53) instead of the planned one'
56
+ type: string
57
+
58
+ permissions:
59
+ contents: read
60
+
61
+ defaults:
62
+ run:
63
+ # -eo pipefail: a failed command in front of a pipe fails the step.
64
+ shell: bash
65
+
66
+ jobs:
67
+ plan:
68
+ runs-on: ubuntu-24.04
69
+ outputs:
70
+ matrix: ${{ steps.plan.outputs.matrix }}
71
+
72
+ steps:
73
+ - name: "[Setup] Checkout code"
74
+ uses: actions/checkout@v7
75
+
76
+ - name: "[Setup] Plan the legs"
77
+ id: plan
78
+ env:
79
+ LEG: ${{ inputs.leg || 'all' }}
80
+ RUBY_VERSION_INPUT: ${{ inputs.ruby-version }}
81
+ RAILS_VERSION_INPUT: ${{ inputs.rails-version }}
82
+ # The schedule tests what a customer installs today, pushes and pull requests test the checkout.
83
+ CLIENT_FROM: ${{ github.event_name == 'schedule' && 'rubygems' || inputs.source || 'checkout' }}
84
+ run: |
85
+ MATRIX=$(bash e2e/plan.sh)
86
+ echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
87
+ jq . <<<"$MATRIX"
88
+
89
+ test:
90
+ needs: plan
91
+ name: ${{ matrix.name }}
92
+ # Forks and Dependabot get no repository secrets, so their pull requests run only the plan.
93
+ if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]') }}
94
+ runs-on: ${{ matrix.runner }}
95
+ timeout-minutes: 20
96
+ strategy:
97
+ fail-fast: false
98
+ matrix:
99
+ include: ${{ fromJSON(needs.plan.outputs.matrix) }}
100
+
101
+ env:
102
+ LEG: ${{ matrix.leg }}
103
+ PLANNED_RUBY: ${{ matrix.ruby }}
104
+ RAILS: ${{ matrix.rails }}
105
+ RAILS_REF: ${{ matrix.rails_ref }}
106
+ SQLITE3: ${{ matrix.sqlite3 }}
107
+ LOGTAIL: ${{ matrix.logtail }}
108
+ LOGTAIL_RACK: ${{ matrix.logtail_rack }}
109
+ CLIENT_FROM: ${{ matrix.source }}
110
+ CLIENT: ${{ matrix.client }}
111
+
112
+ steps:
113
+ - name: "[Setup] Checkout code"
114
+ uses: actions/checkout@v7
115
+
116
+ - name: "[Setup] Ruby ${{ matrix.ruby }}"
117
+ uses: ruby/setup-ruby@v1
118
+ with:
119
+ ruby-version: ${{ matrix.ruby }}
120
+
121
+ - name: "[Setup] Build and install the client"
122
+ run: |
123
+ if [ "$CLIENT_FROM" = rubygems ]; then
124
+ echo "The example takes logtail-rails $CLIENT, its newest release, from rubygems.org."
125
+ else
126
+ # The gem the Release workflow builds and pushes to rubygems.org, installed without its dependencies: the
127
+ # next step installs those at the leg's versions, and Bundler takes this gem as it is installed.
128
+ gem build logtail-rails.gemspec
129
+ gem install --local --ignore-dependencies "logtail-rails-$CLIENT.gem"
130
+ fi
131
+
132
+ - name: "[Setup] Install the example's gems at the leg's versions"
133
+ run: bash e2e/example.sh install
134
+
135
+ - name: "[Verify] Installed versions are the planned ones"
136
+ working-directory: example-project
137
+ run: |
138
+ bundle exec ruby ../e2e/installed.rb > ../out/installed.txt
139
+ installed() { sed -n "s/^$1=//p" ../out/installed.txt; }
140
+ if [ "$CLIENT_FROM" = checkout ]; then FROM="the gem built here"; else FROM="rubygems.org"; fi
141
+ RAILS_FROM=$(installed rails)
142
+ [ "$RAILS" != main ] || RAILS_FROM="$RAILS_FROM from main at $(installed rails_ref | cut -c 1-7)"
143
+ echo "- $(installed ruby_description), Rails $RAILS_FROM, logtail-rails $(installed client) from $FROM, logtail $(installed logtail), logtail-rack $(installed logtail_rack), sqlite3 $(installed sqlite3)" | tee -a ../out/versions.md "$GITHUB_STEP_SUMMARY"
144
+ # The rows to expect depend on the Rails version, see since_rails in e2e/expected.json.
145
+ echo "RAILS_INSTALLED=$(installed rails)" >> "$GITHUB_ENV"
146
+ FAILED=0
147
+ # The planned Ruby is a minor (3.2), an exact version (4.0.9) or head, which reports patch level -1.
148
+ RUBY=$(installed ruby)
149
+ case "$PLANNED_RUBY" in
150
+ head) [ "$(installed ruby_dev)" = true ] || { echo "::error::Ruby $RUBY is running, but the $LEG leg plans ruby-head."; FAILED=1; } ;;
151
+ *) case "$RUBY" in
152
+ "$PLANNED_RUBY" | "$PLANNED_RUBY".*) ;;
153
+ *) echo "::error::Ruby $RUBY is running, but the $LEG leg plans $PLANNED_RUBY."; FAILED=1 ;;
154
+ esac ;;
155
+ esac
156
+ if [ "$RAILS" = main ]; then
157
+ case "$(installed rails_ref)" in
158
+ "$RAILS_REF"*) ;;
159
+ *) echo "::error::Rails $(installed rails) at '$(installed rails_ref)' is installed, but the $LEG leg plans Rails' main branch at $RAILS_REF."; FAILED=1 ;;
160
+ esac
161
+ elif [ "$(installed rails)" != "$RAILS" ]; then
162
+ echo "::error::Rails $(installed rails) is installed, but the $LEG leg plans $RAILS."; FAILED=1
163
+ fi
164
+ # Empty for the canary, which takes whatever Bundler picks and only reports it.
165
+ for check in "logtail $(installed logtail) $LOGTAIL" "logtail-rack $(installed logtail_rack) $LOGTAIL_RACK" "logtail-rails $(installed client) $CLIENT"; do
166
+ read -r name got want <<<"$check"
167
+ if [ -n "$want" ] && [ "$got" != "$want" ]; then
168
+ echo "::error::$name $got is installed, but the $LEG leg plans $want."
169
+ FAILED=1
170
+ fi
171
+ done
172
+ # And the installed client is the gem built here, not a release of the same version from rubygems.org:
173
+ # RubyGems keeps the .gem it installed from in its cache, and the installed lib/ is the checkout's, file for file.
174
+ if [ "$CLIENT_FROM" = checkout ]; then
175
+ if ! cmp -s "../logtail-rails-$CLIENT.gem" "$(installed client_gem)"; then
176
+ echo "::error::logtail-rails was not installed from the gem built here: $(installed client_gem) differs from logtail-rails-$CLIENT.gem."
177
+ FAILED=1
178
+ fi
179
+ if ! diff -r ../lib "$(installed client_dir)/lib" > ../out/client.diff; then
180
+ head -n 20 ../out/client.diff
181
+ echo "::error::The installed gem's lib/ in $(installed client_dir) differs from the checkout's lib/ directory."
182
+ FAILED=1
183
+ fi
184
+ fi
185
+ exit "$FAILED"
186
+
187
+ # Before a source is created: an app that cannot even boot with these versions needs no round trip. Without a
188
+ # source token the gem logs to STDOUT.
189
+ - name: "[Verify] The example app boots with these versions"
190
+ working-directory: example-project
191
+ run: |
192
+ if ! bin/rails runner 'puts "Rails #{Rails.version} booted the example app"' > ../out/boot.log 2>&1; then
193
+ cat ../out/boot.log
194
+ echo "::error::The example app does not boot: $(grep -m 1 -E 'Error|error' ../out/boot.log || tail -n 1 ../out/boot.log)"
195
+ exit 1
196
+ fi
197
+ cat ../out/boot.log
198
+
199
+ - name: "[Setup] Create a source"
200
+ id: source
201
+ uses: ./.github/actions/create-source
202
+ with:
203
+ api-token: ${{ secrets.TEST_TEAM_TELEMETRY_API }}
204
+ name: e2e-rails-gh${{ github.run_id }}a${{ github.run_attempt }}-${{ matrix.leg }}
205
+ platform: ruby
206
+
207
+ - name: "[Test] Run the example project"
208
+ env:
209
+ BETTER_STACK_SOURCE_TOKEN: ${{ steps.source.outputs.token }}
210
+ BETTER_STACK_INGESTING_HOST: ${{ steps.source.outputs.ingesting-host }}
211
+ run: bash e2e/example.sh run
212
+
213
+ - name: "[Setup] The rows Rails ${{ env.RAILS_INSTALLED }} logs"
214
+ run: |
215
+ # The rows marked since_rails that this Rails is too old for are left out.
216
+ jq --arg rails "$RAILS_INSTALLED" 'def minor: split(".")[0:2] | map(tonumber);
217
+ map(select((.since_rails // "0") | minor <= ($rails | minor)) | del(.since_rails))' e2e/expected.json > out/expected.json
218
+ echo "$(jq length out/expected.json) of $(jq length e2e/expected.json) rows in e2e/expected.json"
219
+
220
+ - name: "[Verify] Read the rows back"
221
+ uses: ./.github/actions/verify-logs
222
+ with:
223
+ connection-username: ${{ secrets.TEST_TEAM_CONNECTION_USERNAME }}
224
+ connection-password: ${{ secrets.TEST_TEAM_CONNECTION_PASSWORD }}
225
+ connect-host: ${{ steps.source.outputs.connect-host }}
226
+ collection: ${{ steps.source.outputs.collection }}
227
+ expected: out/expected.json
228
+
229
+ - name: "[Cleanup] Delete this job's source and stale ones of earlier runs"
230
+ if: ${{ always() }}
231
+ uses: ./.github/actions/cleanup-sources
232
+ with:
233
+ api-token: ${{ secrets.TEST_TEAM_TELEMETRY_API }}
234
+ prefix: e2e-rails-
235
+ id: ${{ steps.source.outputs.id }}
236
+
237
+ - name: "[Report] Job summary"
238
+ if: ${{ always() }}
239
+ run: |
240
+ if [ -f out/summary.md ]; then
241
+ { echo; cat out/summary.md; } >> "$GITHUB_STEP_SUMMARY"
242
+ # The Linear issue names the job and links the run, so it gets the versions, the read-back and the
243
+ # cleanup only when that left something behind. Without a read-back summary the notifier takes the
244
+ # job's ::error:: lines instead.
245
+ mkdir -p report
246
+ { cat out/versions.md; echo; cat out/summary.md; } > report/summary.md
247
+ if grep -q '❌' out/cleanup.md 2>/dev/null; then { echo; cat out/cleanup.md; } >> report/summary.md; fi
248
+ else
249
+ { echo; echo "❌ Stopped before the rows were read back, see the failed step."; } >> "$GITHUB_STEP_SUMMARY"
250
+ fi
251
+ if [ -f out/cleanup.md ]; then { echo; cat out/cleanup.md; } >> "$GITHUB_STEP_SUMMARY"; fi
252
+
253
+ - name: "[Report] Hand the summary to the Linear issue"
254
+ if: ${{ always() }}
255
+ uses: actions/upload-artifact@v7
256
+ with:
257
+ name: e2e-summary-${{ matrix.name }}
258
+ path: report/summary.md
259
+ if-no-files-found: ignore
260
+ retention-days: 7
261
+ # A re-run attempt uploads under the same name.
262
+ overwrite: true
263
+
264
+ notify-linear:
265
+ if: ${{ failure() && (github.ref == 'refs/heads/main' || github.event_name == 'schedule') }}
266
+ needs: [test]
267
+ # The notifier reads the failed jobs, their summary artifacts and their annotations.
268
+ permissions:
269
+ contents: read
270
+ actions: read
271
+ checks: read
272
+ uses: ./.github/workflows/notify_linear_failure.yml
273
+ with:
274
+ job_summaries: true
275
+ secrets: inherit