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 +4 -4
- data/.github/actions/cleanup-sources/action.yml +33 -0
- data/.github/actions/cleanup-sources/run.sh +51 -0
- data/.github/actions/create-source/action.yml +46 -0
- data/.github/actions/create-source/run.sh +40 -0
- data/.github/actions/verify-logs/action.yml +47 -0
- data/.github/actions/verify-logs/run.sh +104 -0
- data/.github/workflows/e2e.yml +275 -0
- data/.github/workflows/main.yml +6 -20
- data/.github/workflows/notify_linear_failure.yml +153 -0
- data/e2e/example.sh +66 -0
- data/e2e/expected.json +109 -0
- data/e2e/installed.rb +20 -0
- data/e2e/plan.sh +112 -0
- data/example-project/Gemfile +2 -4
- data/example-project/README.md +9 -7
- data/example-project/app/controllers/example_controller.rb +14 -7
- data/example-project/app/views/example/index.html.erb +3 -1
- data/example-project/config/application.rb +5 -4
- data/example-project/config/routes.rb +1 -0
- data/lib/logtail-rails/version.rb +1 -1
- data/lib/logtail-rails.rb +4 -0
- metadata +13 -2
- data/gemfiles/rails-edge.gemfile +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ce3c0d5b067b69401f94dd38e671d089ec35acdc64fcf02aff7b77360c7f268
|
|
4
|
+
data.tar.gz: 134e79823c37bc41fea97a49946cc964b58c19729261ceffc137a05ff837987c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|