Skip to content

Count the readers who stayed, and say where they are - #249

Merged
openipc-ai merged 4 commits into
masterfrom
engaged-readers
Sep 21, 2026
Merged

openipc-ai merged 4 commits into
masterfrom
engaged-readers

Conversation

@openipc-ai

Copy link
Copy Markdown
Collaborator

Refs #184. Two commits: the metric, then the country split.

Why

readers is a deliberately low bar — one page outside the wall — and a number that low moves with whatever got linked somewhere yesterday. #184 wants figures that mean the same thing in month three as in month one, and that one does not.

Engaged readers is the subset who went past the page they landed on: five or more page views in a day, counted outside the wall.

It is a subset of the existing readers rather than a new classification, which matters more than it sounds: the impossible-device fingerprint and the Accept-Language test have already run before a visitor can reach it, so it inherits every correction those have accumulated rather than repeating their mistakes.

    readers                  3  reached a page outside the wall
      engaged                0  read 5+ pages outside the wall
  ...
  engaged, previous         73  on 2026-09-20 (+43, +59%)
  engaged readers by country, against 2026-09-20
        26   21%  CN China               (+22)
        14   11%  US United States       (+1)
        12   10%  RU Russia              (+6)
         6    5%  PL Poland              (-2)
         4    3%  UA Ukraine             (-59)

That output is from a real run on the origin against the actual log, not from the fixture.

What it is careful about

Wall views do not count toward it. views_by includes them because wall-once needs them; this one must not, or a gallery visitor who scrolled five images walks back into the audience by the other door — which is the split the wall separation exists to make.

The threshold is per day, never pooled. Over a month a crawler fetching one page a day clears five; per day it never does. It is printed alongside the number, so a figure cannot outlive the definition that produced it, and ENGAGED_MIN is one place rather than scattered through the awk.

Five is a judgement, not a discovery, and the comment says so: on 2026-09-21 the wall-excluded day split into 290 addresses with one view, 145 with two to four, 57 with five to nine and 64 with ten or more, so the ambiguous band is the middle one.

The comparison takes the row before that date, not the line above, so backfilling an older day does not claim a change that never happened. Re-running a day replaces its row rather than leaving the series with two answers for one date.

The country split, and three traps in goaccess's CSV

GoAccess is the only thing on the origin that can read the country database — no mmdblookup, no python binding — so the split comes out of its CSV. Each of these cost a run to find and each is now documented beside the parse:

  1. It is CRLF. An anchored match finds nothing at the end of a line until the carriage return is gone.
  2. Empty columns are bare commas, not empty quoted fields, so a split on the quote-comma does not separate them. That sorts the rows for free: a continent row arrives with its first two columns welded together and a percentage in $3, a country row splits cleanly. Testing $3 keeps countries and drops continents — which would otherwise be counted on top of the countries inside them, and would put "AS Asia" at the head of the list.
  3. The country is not the last field either, for the same reason — the tail reads ,,,"CN China". It is matched off the end of the line.

Addresses never leave the work directory the trap removes, and only counts are printed. An address in this output would make the nightly the individual record /privacy says the site does not keep. There is a test for that specifically, because it is the kind of thing a later change adds back by accident.

By address, not by visitor: one address carrying two engaged browsers is two engaged readers in the count above and one line here. The alternative is geolocating inside this script, which needs a database reader it does not have.

Incidental fixes

  • day is 20260921 from a log filename and 2026-09-21 from date. Two formats in one column would have broken the ordering the history depends on, because - sorts before every digit. Normalised where it is used.
  • visitor_report now takes the counts rather than the log, because the nightly needs the same numbers twice and a second pass over a seventy-megabyte log is one nobody reading the output could account for.
  • db is overridable through OPENIPC_GEOIP_DB, which is what makes the country path testable without the installed database, and lets a run by hand point at a copy.

Tested

  • The subset relation at three thresholds; that wall views do not count; that the threshold is printed.
  • The history file, its replace-on-rerun, and the comparison line.
  • The country split: countries counted and continents not, the per-day file, the comparison, and that no address reaches the output.
  • bash -n, because most of this script is awk inside single-quoted shell strings and an apostrophe in a comment closes one. That is how the word "today's" broke it while this was being written, and the nightly fails into a mail nobody opens.
  • Full suite 787 runs / 3,518 assertions, 0 failures. Shellcheck clean. Rubocop unchanged from master on the touched files.
  • mawk and gawk agree, checked on the origin against the real log, where both are installed and gawk is the default.

One thing the real run already showed

It confirms the loose thread noted on #184: Ukraine falls 63 → 4 between the 20th and the 21st and Finland 61 → nothing, while China rises 4 → 26. That is the /teleoperation launch spike rather than a baseline — exactly the distortion that would have poisoned an early reading, and it is now visible in the report itself instead of being something someone has to remember.

Two days of data is not a trend, and no country ordering here should be acted on yet; the memo can start carrying this from the first full month after 20 September.

`readers` is a deliberately low bar -- one page outside the wall -- and a
number that low moves with whatever got linked somewhere yesterday, which
makes it a poor thing to compare months with. #184 wants figures that mean
the same in month three as in month one.

Engaged readers is the subset who went past the page they landed on: five
or more page views in a day, counted OUTSIDE the wall. It is a subset
rather than a new classification, so the impossible-device fingerprint and
the Accept-Language test have already been applied before a visitor can
reach it, and it inherits every correction those have accumulated.

Three things it is careful about.

Wall views do not count toward it. `views_by` includes them because
`wall-once` needs them; this one must not, or a gallery visitor who
scrolled five images walks back into the audience by the other door --
which is the split the wall separation exists to make.

The threshold is applied per day, never pooled across a period. Over a
month a crawler fetching one page a day clears five; per day it never
does. It is also printed with the number, so a figure cannot outlive the
definition that produced it, and ENGAGED_MIN is one place rather than
scattered through the awk.

Five is a judgement rather than a discovery, and the comment says so: on
2026-09-21 the wall-excluded day split into 290 addresses with one view,
145 with two to four, 57 with five to nine and 64 with ten or more, so the
ambiguous band is the middle one.

The comparison needs a trail, so the nightly now appends a row a day to
engaged.tsv -- date, visitors, readers, engaged -- and prints the previous
run's number with the change. Three columns because engaged cannot be read
without the readers it is drawn from, nor those without the visitors they
are filtered from. Re-running a day replaces its row instead of leaving
the series with two answers for one date, and the comparison takes the row
before THAT date rather than the line above, so backfilling an older day
does not claim a change that never happened.

One latent bug fixed on the way: `day` is 20260921 when it comes from a
log's filename and 2026-09-21 when it comes from `date`. Two formats in
one column would have broken the ordering the file depends on, because '-'
sorts before every digit. Normalised where it is used.

visitor_report now takes the counts rather than the log, because the
nightly needs the same numbers twice and a second pass over a
seventy-megabyte log to re-derive them is a pass nobody reading the output
could account for.

Tested: the subset relation at three thresholds, that wall views do not
count, that the threshold is printed, the history file and its
replace-on-rerun, and the comparison line. Plus `bash -n`, because most of
this script is awk inside single-quoted shell strings and an apostrophe in
a comment closes one -- which is how the word "today's" broke it while
this was being written, and the nightly fails into a mail nobody opens.

mawk and gawk agree, checked on the origin where both are installed.

Refs #184.
#184 asks where the audience is, and the engaged count on its own cannot
answer it. This adds the split, and compares each country with the run
before in the same way the total already is.

GoAccess is the only thing on the origin that can read the country
database -- there is no mmdblookup and no python binding -- so the split
comes out of its CSV rather than from looking addresses up directly. Three
things about that format each cost a run to find, and all three are now
written down beside the parse:

It is CRLF, so an anchored match finds nothing at the end of a line until
the carriage return is gone.

Its empty columns are bare commas rather than empty quoted fields, so a
split on the quote-comma does not separate them. That turns out to sort
the rows for free: a continent row arrives with its first two columns
welded together and a percentage in $3, while a country row splits
cleanly, so testing $3 keeps the countries and drops the continents --
which would otherwise be counted a second time on top of the countries
inside them, and would put "AS Asia" at the head of the list.

The same welding leaves the tail of a row at ,,,"CN China", so the country
is not the last field either. It is matched off the end of the line.

Addresses never leave the work directory the trap removes, and only counts
are printed: an address in this output would make the nightly the
individual record /privacy says the site does not keep. There is a test
for that, because it is the kind of thing a later change adds back by
accident.

By address rather than by visitor, and the comment says so -- one address
carrying two engaged browsers is two engaged readers in the count above
and one line here. The alternative is geolocating inside this script,
which needs a database reader it does not have.

A country absent from the previous day had no engaged readers that day, so
absence is zero and every row carries a change. The column is dropped
entirely on a first run, where each number would otherwise read as itself.

`db` is now overridable through OPENIPC_GEOIP_DB, which is what makes any
of this testable without the installed database, and lets a run by hand
point at a copy.

Checked against the real log on the origin, where it also confirms the
loose thread noted on #184: Ukraine falls 63 to 4 between the 20th and the
21st, and Finland 61 to nothing, which is the /teleoperation launch spike
rather than a baseline. China rises 4 to 26.

mawk and gawk agree on the split, checked on the origin against that same
log where both are installed.

Refs #184.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Track engaged readers and daily country trends

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Count readers reaching a configurable daily depth, excluding wall views.
• Persist daily engagement and compare against the preceding recorded date.
• Report privacy-preserving country shares and changes through GoAccess geolocation.
Diagram

graph TD
  LOG[("Access Log")] --> CLASS["Visitor Classifier"] --> REPORT["Nightly Report"]
  CLASS --> HISTORY[("Engagement History")]
  CLASS --> GOACCESS["GoAccess CSV"] --> PARSER["Country Parser"] --> COUNTRY[("Country History")]
  GEO[("GeoIP Database")] --> GOACCESS
  LOG --> GOACCESS
  PARSER --> REPORT
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Direct MaxMind database lookup
  • ➕ Avoids coupling to GoAccess CSV formatting quirks.
  • ➕ Could preserve visitor-level rather than address-level country counts.
  • ➕ Provides a narrower geolocation-only processing path.
  • ➖ Requires installing and maintaining another database reader or language binding.
  • ➖ Introduces additional privacy-sensitive address-processing code.
  • ➖ Duplicates country lookup functionality already available through GoAccess.
2. Store metrics in a relational database
  • ➕ Supports safer upserts and flexible monthly trend queries.
  • ➕ Avoids repeatedly scanning and sorting TSV history files.
  • ➖ Adds schema, migration, connection, and operational complexity.
  • ➖ Is disproportionate for one small append-or-replace record per nightly run.

Recommendation: Keep the PR’s GoAccess and TSV approach for the current deployment: it reuses the only installed GeoIP reader, stores only aggregates, and keeps the nightly pipeline self-contained. A direct database lookup would become preferable only if a supported binding is added for other needs, since it would eliminate the deliberately defensive but brittle CSV parsing.

Files changed (2) +441 / -10

Enhancement (1) +216 / -9
audience-report.shCalculate, persist, and geolocate engaged-reader metrics +216/-9

Calculate, persist, and geolocate engaged-reader metrics

• Adds a configurable per-day engagement threshold based only on non-wall page views and reuses the resulting counts across report stages. It records replace-on-rerun daily histories, compares each run with its chronological predecessor, and derives aggregate country shares through GoAccess without exposing addresses. It also normalizes history dates and makes the GeoIP database path configurable.

deploy/audience-report.sh

Tests (1) +225 / -1
audience_report_test.rbCover engagement thresholds, histories, geolocation, and privacy +225/-1

Cover engagement thresholds, histories, geolocation, and privacy

• Adds full-report helpers with a stubbed GoAccess CSV source and configurable threshold and database paths. Tests verify reader subset behavior, wall exclusion, threshold disclosure, chronological comparisons, rerun replacement, country parsing, continent exclusion, and address redaction. A shell syntax test guards the embedded single-quoted awk programs.

test/deploy/audience_report_test.rb

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 21, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Country totals undercount shared readers ✓ Resolved 📎 Requirement gap ≡ Correctness
Description
visitors increments engaged per address–user-agent identity, while engaged_at retains only
addresses and engaged_countries both deduplicates those addresses and then passes every matching
log record to GoAccess, whose visitor count can represent multiple browsers. When engaged and
non-engaged browsers share an address, or multiple engaged browsers use it, the country path
measures a different population from the audience total and may either collapse qualifying
identities or include additional browsers from the same address.
Code

deploy/audience-report.sh[179]

+              engaged_at[substr(v, 1, index(v, "|") - 1)] = 1
Evidence
Compliance item 2 requires the people-per-day country segmentation to use the same engaged-reader
population as the overall metric, but the implementation counts engagement by address–user-agent and
then switches to addresses for geolocation. Although the code describes one country count per
address, it filters the full source log by each qualifying address and consumes GoAccess field $6,
documented locally as the visitor count; the fixture includes an engaged Firefox identity and a
separate non-engaged iPhone identity at the same address, and the tests stub the final CSV, so they
do not detect either population collapse or inflation.

Report beacon audience and section metrics
deploy/audience-report.sh[174-180]
deploy/audience-report.sh[352-355]
deploy/audience-report.sh[364-368]
deploy/audience-report.sh[364-395]
test/fixtures/files/beacon-access.log[7-10]
test/deploy/audience_report_test.rb[43-64]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The country split does not preserve the address–user-agent identities used by the engaged-reader total: it selects unique engaged addresses, feeds all records sharing those addresses to GoAccess, and consumes a visitor-based result. This can collapse multiple engaged identities or include non-engaged browsers from the same address, preventing the country counts from reconciling with the population they are intended to segment.
## Fix Focus Areas
- deploy/audience-report.sh[174-180]
- deploy/audience-report.sh[352-395]
- deploy/audience-report.sh[364-373]
- deploy/audience-report.sh[391-397]
- test/fixtures/files/beacon-access.log[7-10]
- test/deploy/audience_report_test.rb[43-64]
## Recommended Fix
Retain each engaged visitor's address–user-agent identity and construct the GoAccess input from only records belonging to those qualifying visitor sessions, rather than every request sharing a qualifying address. If the implementation intentionally keeps an address-based country metric, ensure each engaged address contributes exactly one valid representative record instead of consuming a visitor-based count from all matching records. Add an integration-style test using the existing same-address, two-user-agent fixture without supplying arbitrary final visitor totals from the GoAccess stub, and verify that country counts reconcile with the geolocated engaged-reader count.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Country figures hide their source ✓ Resolved 📎 Requirement gap ◔ Observability
Description
engaged_countries prints its heading, counts, shares, and deltas without naming the beacon,
GoAccess CSV, or country database that produced them. Whenever this nightly output supplies a
monthly memo, its country measurements and persisted comparisons are presented without the source
attribution needed to interpret them.
Code

deploy/audience-report.sh[R418-420]

+    printf '  engaged readers by country, against %s\n' "$prev_day"
+  else
+    printf '  engaged readers by country\n'
Evidence
Compliance items 2 and 5 require each audience figure to identify its source explicitly. The new
output labels the figures only as engaged readers by country even though the values are derived
through the beacon log, GoAccess CSV, and the configured country database.

Report beacon audience and section metrics
Preserve memo comparability across months
deploy/audience-report.sh[370-373]
deploy/audience-report.sh[418-436]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new country measurements do not explicitly identify the beacon and geolocation sources in their emitted output or persisted comparison data.
## Fix Focus Areas
- deploy/audience-report.sh[401-405]
- deploy/audience-report.sh[418-436]
## Recommended Fix
Add a scoped source label identifying the beacon-derived engaged population and the GoAccess/DB-IP geolocation method. Preserve equivalent source and method metadata with the country history so later monthly comparisons remain attributable.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Two log fixtures exceed line limit 📘 Rule violation ⚙ Maintainability
Description
The added access-log heredoc rows at lines 337 and 341 exceed the configured 120-character
Layout/LineLength maximum. RuboCop therefore reports new offenses in the modified test file when
checking these gallery fixtures.
Code

test/deploy/audience_report_test.rb[337]

+        198.51.100.60 - - [21/Sep/2026:01:09:0#{n} +0000] "POST /api/a/count?p=%2Fsnapshots%2F30#{n}&t=Open%20Wall&s=1920&b=0&rnd=jjjj#{n} HTTP/2.0" 200 43 "https://openipc.org/snapshots/30#{n}" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36" xff="-" cache=- rt=0.002 urt="0.002" al="en-US,en;q=0.9" peer=198.51.100.60
Evidence
Compliance item 7 prohibits new RuboCop offenses in modified files, and the repository config sets
Layout/LineLength to 120. Both cited heredoc rows were added by this PR and exceed that maximum.

CLAUDE.md: Do Not Introduce New RuboCop Offenses in Modified Files: CLAUDE.md: Do Not Introduce New RuboCop Offenses in Modified Files: CLAUDE.md: Do Not Introduce New RuboCop Offenses in Modified Files: CLAUDE.md: Do Not Introduce New RuboCop Offenses in Modified Files: CLAUDE.md: Do Not Introduce New RuboCop Offenses in Modified Files: CLAUDE.md: Do Not Introduce New RuboCop Offenses in Modified Files: CLAUDE.md: Do Not Introduce New RuboCop Offenses in Modified Files: CLAUDE.md: Do Not Introduce New RuboCop Offenses in Modified Files
.rubocop.yml[3-4]
test/deploy/audience_report_test.rb[337-337]
test/deploy/audience_report_test.rb[341-341]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Two newly added access-log fixture rows exceed the repository's configured 120-character Ruby line limit.
## Fix Focus Areas
- test/deploy/audience_report_test.rb[337-341]
## Recommended Fix
Construct each logical log row from shorter Ruby string fragments joined without inserting a newline, preserving the exact one-record-per-line fixture content while keeping every source line within 120 characters.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View action required (1)
4. Zero-reader days retain old countries ✓ Resolved 🐞 Bug ≡ Correctness
Description
engaged_countries returns on an empty engaged-address list before replacing that date's rows in
engaged-countries.tsv. A zero-engagement rerun therefore leaves its former countries intact, and a
newly recorded zero day is absent so the next comparison skips back to an older nonzero day.
Code

deploy/audience-report.sh[R364-365]

+  awk '$1 == "engaged-address" { print $2 }' <<< "$counts" | sort -u > "$work/engaged-addresses"
+  [ -s "$work/engaged-addresses" ] || return 0
Evidence
The empty-address guard occurs before the history rewrite, while previous-country selection derives
its previous date solely from rows present in the country history. Thus zero days cannot replace
existing data or participate as the immediately preceding run.

deploy/audience-report.sh[358-366]
deploy/audience-report.sh[399-413]
deploy/audience-report.sh[423-436]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Country history is updated only when the current day has country rows, causing zero-reader reruns to retain stale rows and future comparisons to skip zero-reader days.
## Fix Focus Areas
- deploy/audience-report.sh[358-365]
- deploy/audience-report.sh[399-413]
## Recommended Fix
Remove existing rows for the current date before handling an empty engaged-address result and persist an explicit day marker representing a successful zero-country run. Teach previous-day selection to recognize that marker as a real run with all country counts equal to zero, while keeping lookup failures distinct from successful empty results.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Combined logs inflate engagement ✓ Resolved 🐞 Bug ≡ Correctness
Description
visitors increments site_views in one visitor-keyed array without including or validating the
request date. Supplying the documented command with a log spanning multiple days pools that
visitor's views, so repeated low-depth daily activity can cross the threshold despite the metric
being defined per day.
Code

deploy/audience-report.sh[R138-142]

+        # Depth, counted outside the wall only. views_by below includes wall
+        # views because wall-once needs them; this one must not, or a gallery
+        # visitor who scrolled five images would read as an engaged reader,
+        # which is the population the wall split exists to keep separate.
+        else { elsewhere[visitor] = 1; site_views[visitor]++ }
Evidence
The script advertises an arbitrary log argument and stresses that engagement must never be pooled
across a period, but the new counter is keyed only by visitor; the awk program never extracts the
timestamp date.

deploy/audience-report.sh[16-17]
deploy/audience-report.sh[27-38]
deploy/audience-report.sh[59-102]
deploy/audience-report.sh[135-179]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The engagement counter pools all records in the supplied log even though the threshold is explicitly defined per day, and the script accepts arbitrary log paths for both report modes.
## Fix Focus Areas
- deploy/audience-report.sh[16-17]
- deploy/audience-report.sh[27-38]
- deploy/audience-report.sh[59-179]
## Recommended Fix
Parse the date from each accepted beacon record and either key engagement counters by date and visitor or reject inputs containing more than one date. Add a test where one visitor has sub-threshold views on each of two dates and verify that those views never combine into engagement.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Country lookup failures pass silently ✓ Resolved 🐞 Bug ◔ Observability
Description
engaged_countries masks every nonzero GoAccess exit with || true and subsequently returns
successfully when no parsed rows exist. A database, invocation, input-format, or CSV-format failure
therefore removes the country section without failing the nightly report or identifying the failed
stage.
Code

deploy/audience-report.sh[R370-373]

+  goaccess "$work/engaged.log" -o csv \
+    --log-format='%h - %^ [%d:%t %^] "%r" %s %b "%R" "%u" xff="%^" cache=%^ rt=%T urt="%^" al="%^" peer=%^' \
+    --date-format='%d/%b/%Y' --time-format='%H:%M:%S' \
+    --no-progress --geoip-database "$db" 2>/dev/null > "$work/engaged.csv" || true
Evidence
The function has already established that engaged addresses and a readable database exist before
invoking GoAccess, yet it discards stderr and status; an empty parsed file then causes an unreported
successful return to the caller.

deploy/audience-report.sh[364-373]
deploy/audience-report.sh[391-399]
deploy/audience-report.sh[568-571]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The country lookup suppresses GoAccess failures and treats their empty output like a legitimate absence of country data, leaving operators without a failure signal.
## Fix Focus Areas
- deploy/audience-report.sh[364-399]
- deploy/audience-report.sh[568-571]
## Recommended Fix
Capture GoAccess diagnostics and check its exit status instead of unconditionally masking it. Return a nonzero status or print a clear warning for invocation and parse failures, while preserving a separate successful path for a genuinely empty engaged population.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Threshold changes create false trends ✓ Resolved 🐞 Bug ≡ Correctness
Description
record_history stores the engaged count without the engaged-min value that defined it, and the
country history likewise carries no threshold metadata. When ENGAGED_MIN is deliberately changed
through its supported environment override, both comparison paths calculate deltas between
incompatible populations and present them as audience movement.
Code

deploy/audience-report.sh[R307-310]

+  total=$(awk '$1 == "visitors" { print $2 }' <<< "$counts")
+  readers=$(awk '$1 == "readers" { print $2 }' <<< "$counts")
+  engaged=$(awk '$1 == "engaged" { print $2 }' <<< "$counts")
+
Evidence
The threshold is configurable and printed in the current report, but the global history schema
contains only date, visitors, readers, and engaged count, while country rows contain only date,
country, and count. Both comparison routines consequently lack the information required to detect a
changed definition.

deploy/audience-report.sh[27-38]
deploy/audience-report.sh[307-338]
deploy/audience-report.sh[401-436]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Historical counts omit the engagement threshold, so supported threshold changes make subsequent global and country deltas compare different definitions as though they were equivalent.
## Fix Focus Areas
- deploy/audience-report.sh[27-38]
- deploy/audience-report.sh[293-340]
- deploy/audience-report.sh[401-436]
## Recommended Fix
Persist the active threshold with each global and country history date. Before calculating a delta, verify that the prior threshold matches the current one; otherwise print an explicit definition-change message and suppress the numerical comparison.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (1)
8. Concurrent runs lose history rows ✓ Resolved 🐞 Bug ☼ Reliability
Description
Both history functions perform an unlocked read-modify-write against shared files and overwrite them
directly with sort -o. When a scheduled report overlaps a supported hand run or backfill, each can
read the same old state and the later writer discards the other run's newly added rows.
Code

deploy/audience-report.sh[R313-315]

+  [ -f "$history" ] && awk -v d="$day" -F'\t' '$1 != d' "$history" > "$scratch"
+  printf '%s\t%s\t%s\t%s\n' "$day" "$total" "$readers" "$engaged" >> "$scratch"
+  sort -o "$history" "$scratch"
Evidence
Each function copies the current shared history into a process-local scratch file, appends only its
own data, and overwrites the shared destination without locking. The repository documents both cron
execution and hand/backfill runs, so overlapping writers are possible.

deploy/audience-report.sh[290-315]
deploy/audience-report.sh[298-304]
deploy/audience-report.sh[401-406]
deploy/cron.d/openipc-metrics[6-8]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Global and country history updates are unsynchronized read-modify-write operations, allowing overlapping scheduled, manual, or backfill runs to overwrite each other's additions.
## Fix Focus Areas
- deploy/audience-report.sh[290-316]
- deploy/audience-report.sh[401-406]
- deploy/cron.d/openipc-metrics[6-8]
## Recommended Fix
Acquire a shared report-history lock before reading either history and hold it through update and comparison. Write each completed history to a temporary file in the destination directory and atomically rename it into place before releasing the lock.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread deploy/audience-report.sh Outdated
Comment thread deploy/audience-report.sh Outdated
Comment thread test/deploy/audience_report_test.rb
Comment thread deploy/audience-report.sh Outdated
Comment thread deploy/audience-report.sh Outdated
Comment thread deploy/audience-report.sh Outdated
Comment thread deploy/audience-report.sh
Comment thread deploy/audience-report.sh Outdated
Qodo's first finding on #249, and it was right. The country split took the
ADDRESSES of engaged readers and fed every log record sharing one to
GoAccess, whose visitor count is per address and User-Agent. An engaged
reader is an address AND a User-Agent, so the two populations were not the
same: a non-engaged browser at an engaged reader's address was counted,
and the fixture has exactly that shape -- one address running an engaged
Firefox and a one-page iPhone.

Nothing is filtered by address any more. visitors() keeps one line per
visitor, that visitor's own, and hands over the lines of the engaged ones;
GoAccess sees those and nothing else. The totals now reconcile with the
engaged count by construction rather than by argument, and on the origin
against the real log they do: 130 engaged, 130 across countries.

The test asserts what GoAccess is GIVEN rather than what it returns. A
stub cannot geolocate, so a stubbed total would only prove the stub was
believed; the input is the thing the fix changes. Two cases: the shared
address contributes the engaged browser and not the other, and when both
browsers at one address qualify, both lines go over.

Qodo's second finding, also fair: the country figures did not say what
produced them, and #184 asks that every number name its source. The
heading now carries the beacon and the database, and both series files
open with a header naming their columns and method -- they outlive the
report they were printed beside.

That header is written rather than sorted into place. A comment character
sorts ahead of a digit by byte, but the default locale collates
punctuation as though it were absent and had put the line in the middle of
the series. LC_ALL=C on the sort for the same reason: the order of these
files is the comparison.

Its third finding, that two heredoc lines exceed Layout/LineLength, is
wrong. Rubocop allows long lines inside heredocs by default, master
already carries 382- and 370-character log fixtures in this file, and
rubocop reports no offences on either revision.

Refs #184.
Five more findings from Qodo on #249, all of them real.

ZERO DAYS. engaged_countries returned before touching its history when
nobody was engaged, so a re-run left the previous countries standing and a
genuine zero day recorded nothing at all -- after which the next run
looked past it to an older, larger day and printed the difference as
movement. A zero day now rewrites its rows empty, and a day whose lookup
FAILED is kept distinct: it says so and leaves the rows alone, because
overwriting them with nothing would record a fact nobody established.

The deeper cause was that each history file decided separately what the
previous run was. There is now one previous_run(), reading engaged.tsv,
which has a row for every run including zero days; the country file is no
longer asked a question it cannot answer.

POOLED DAYS. Depth was counted per visitor across the whole log, while the
threshold is documented per day. A log spanning two days turned two
shallow days into one engaged one. Depth is now keyed by date and visitor
and a visitor is judged on their deepest single day, which is identical
for a one-day log and correct for any other. A log that spans more than
one day now says so in the output.

THRESHOLD CHANGES. ENGAGED_MIN is overridable and nothing recorded which
value produced a number, so changing it made the next delta a comparison
between two different definitions. The threshold is now stored with each
row, and a comparison across a change is refused and explained rather than
printed.

SILENT FAILURES. `|| true` masked every goaccess failure and an empty
parse looked the same as a day with no countries. Exit status is checked,
stderr is surfaced, and a parse that yields nothing from a non-empty input
warns that the output format may have moved.

CONCURRENCY. Both histories were unlocked read-modify-write against files
a hand run or a backfill can touch while cron is running. Both updates,
and the read of the previous run they compare against, now happen under
one flock.

Verified on the origin against the real log: 133 engaged, 133 across
countries, and mawk and gawk agree on the whole output.

Refs #184.
@openipc-ai
openipc-ai merged commit 8916ba6 into master Sep 21, 2026
2 checks passed
@openipc-ai
openipc-ai deleted the engaged-readers branch September 21, 2026 13:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant