Latency analysis

Turn a campaign’s raw results CSV into a per-campaign recipient-latency report – how fast each message followed the previous one, and what share of recipients hit the fleet’s target windows – so you can tune send timing. Assumes you have completed First-time setup (the package README, or ?s160_gcs_init from R) if you read the export from Cloud Storage; a local CSV needs no sign-in.

Build a report

Read the campaign’s results, then run the report:

library(survey160r)
s160_gcs_init()            # one browser sign-in, cached
data   <- s160_gcs_campaign_results_read(1234, hash = TRUE)  # or s160_read_csv("path/to/1234.csv")
result <- latency_run(1234, data, field_timezone = "America/New_York")

You already read these CSVs in your own scripts; s160_gcs_campaign_results_read(hash = TRUE) and s160_read_csv() just add the source-hash provenance that latency_run() records on result$meta. field_timezone sets the local time zone for the hour-of-day buckets; it defaults to "UTC", so pass the zone the campaign fielded in ("America/New_York", "America/Chicago", …) or hour_local is off by the offset. latency_run() builds the config from the CSV header and returns a list: consolidated (the report), latency_frame (per-respondent detail), diagnostics (parse / NA counts), and meta (provenance).

Customize the config

latency_run() derives the config from the CSV header; override through ..., or build it explicitly to inspect first, then validate before running:

config <- latency_build_config(1234, data,
                               field_timezone = "America/New_York",
                               project_id     = 9999,
                               date_filter    = "2026-01-26")   # keep a single field date
latency_validate_config(config, data)   # fail fast: required columns, flow order, no terminal states
result <- latency_report(data, config)

Run deterministically

latency_report() is the pure entry point (no I/O). Pass run_at to fix run_at_utc – otherwise it comes from the clock – for reproducible output and tests:

latency_report(data, config,
               run_at = as.POSIXct("2026-01-27 12:00:00", tz = "UTC"))

A hand-built data frame works too; result$meta provenance is then NA, which is correct for that case.

Reduce a report to funnel counts

consolidated is denormalized – the funnel anchors repeat across every segment and threshold row, and the frame carries both hour rows and a day rollup – so summing a column over it multi-counts. latency_funnel() does the one correct reduction:

latency_funnel(result$consolidated)                  # one row per (campaign, date)
latency_funnel(result$consolidated, grain = "hour")  # one row per (campaign, date, hour)

Each row carries n_sent, n_engaged, n_opted_in, and n_completed; summing an anchor across the "hour" result equals the matching "day" value.

Count recipients reaching each question

For the “records reaching each question” view – which stage sheds respondents – pass the flow in order to question_funnel(). It reads the export directly, not the report:

question_funnel(data, c("intro", "q1", "q2", "close"))

It returns one row per question with n_reached and pct_reached (share of the first stage). Underneath, question_timestamps(data, "close") reads and parses one question’s timestamp column, and parse_campaign_timestamps() decodes the export’s timestamp strings directly – both operate on the raw export data frame.

Run a large export

A wide, high-volume campaign fits a laptop with three levers, all output-identical to the default read and run:

path   <- "path/to/1234.csv"
header <- s160_csv_header(path)
config <- latency_build_config(1234, header, field_timezone = "America/New_York")
data <- s160_read_csv(path,
                      columns = latency_input_columns(config, available = header),
                      timestamps = "POSIXct")   # 8 bytes per cell, not ~60
result <- latency_run(1234, data, config = config, compact = TRUE)

columns projects the read to the timestamp, population and campaign-id columns the report needs. timestamps = "POSIXct" guarantees compact timestamp columns even where a stray unparseable cell would otherwise keep a column as text (an unparseable cell is still counted in diagnostics$parse_failures_per_column). compact = TRUE computes the same consolidated and diagnostics segment by segment, keeping only the rows with a real send date (it never materializes the full respondent-by-segment frame), so peak memory follows real engagement rather than (questions - 1) x respondents; latency_frame then holds just those rows. A 1M-respondent, 12-question export runs in about 15 seconds within 4 GB.

What you get back

result$consolidated is one row per (campaign_id, date, hour_local, segment, threshold_min) – the shape a consumer project writes to Parquet:

Column Meaning
campaign_id, project_id Wave identity
survey_mode sms / t2w / t2w_external
date, hour_local Bucket grain (hour_local is NA for the day rollup)
segment, segment_index Consecutive flow-question pair (the two names joined by a right-arrow, e.g. intro then q1) and its position
threshold_min Fleet threshold (1, 3, 5, or 10 min)
n, pct_le Per-segment in-window dispatch metrics
n_respondents, pct_resp_hit_gt, pct_resp_worst_gt Respondent-cascade metrics
mean_delta_min, p50_delta_min, p90_delta_min, p95_delta_min Per-segment latency distribution (minutes)
n_na_parse, n_na_missing, n_na_chain Segments with no computable latency, by reason
n_sent, n_engaged, n_opted_in, n_completed, n_ineligible, n_refused Bucket volume: sent, engaged, opted-in, completed, screened out, declined
algorithm_version, config_hash, source_csv_hash, run_at_utc, run_by Provenance

result$meta carries algorithm_version, schema_version, config_hash, run_at_utc, source_csv_hash, and source_csv_path. See ?latency_run for the full argument list.

Notes

  • Run or build. latency_run() composes latency_build_config() and latency_report(); reach for the pieces only when you need to inspect or override the config.
  • No respondent id by default. respondent_id_column defaults to NULL – userid is the agent login, not a per-respondent id.
  • Non-interactive runs. For a scheduled job or a rendered report, authenticate with s160_gcs_init(adc = TRUE) – Application Default Credentials, no browser.

See also

?latency_run, ?latency_report, ?latency_funnel, ?question_funnel, ?question_timestamps, ?parse_campaign_timestamps, ?latency_build_config, ?latency_validate_config, and ?survey160r for every function by task. To screen a phone sample, vignette("disposition") and vignette("opt-out"). For the funnel across the fleet by registration or brand, vignette("campaign-metrics").