# App Release Complaint Regression Monitor (`plusonets/app-release-complaint-monitor`) Actor

Compare public complaint rates before and after an app release, with denominators, sample sizes, and short review examples. A rate change is a time-window association, not proof of cause.

- **URL**: https://apify.com/plusonets/app-release-complaint-monitor.md
- **Developed by:** [Olalekan Johnson](https://apify.com/plusonets) (community)
- **Categories:** Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $500.00 / 1,000 app analyses

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## App Release Complaint Regression Monitor (Google Play)

Unofficial monitor for mobile product teams and app agencies. Give it public app ids, languages, and countries. It collects new and edited reviews, clusters complaints, and compares rates across a before window and an after window.

A row looks like **login complaints 3/150 to 18/160**. The denominator is the number of reviews in that window. An alert is emitted only when both windows are covered, both samples meet the minimum size, and both the percentage-point increase and the relative increase are met. The comparison is a **time-window or version association, not causation**. A higher rate after a date or version does not prove the release caused the complaints.

Unofficial, and not affiliated with Google or Apple. Public review text stays the reviewer's. This Actor keeps a short excerpt for your investigation and redacts email addresses and phone numbers. It does not store profile images or profile links.

Contact: plusonecares@gmail.com

### Who it is for

Teams that already read reviews after a release and need a rate they can check, with the reviews that produced it. It is a poor fit for a low-volume app that never reaches the sample minimum, and it will say so instead of inventing an alert.

### What you provide

| Field | Default | Role |
| --- | --- | --- |
| `appIds` | `com.whatsapp` | Google Play package names |
| `languages` | `en` | Play language code, such as `en` or `pt-BR` |
| `countries` | `us` | Storefront, such as `us` |
| `lookbackDays` | `7` | Timestamp window to compare. Split into two equal halves unless you set a release |
| `maxReviewsPerScope` | `200` | Newest-first cap per app, language, and country. Maximum 1000 |
| `publicationLagDays` | `2` | Extra days re-read every run so a late-appearing review can still be seen |
| `releaseDate` | empty | `YYYY-MM-DD`. Splits every app that has no per-app override |
| `appVersion` | empty | Exact version string. Used only when the store put that version on the review |
| `releases` | empty | Optional `[{ "appId", "version", "releasedAt" }]` |
| `customCategories` | empty | Optional `[{ "name": "notifications", "keywords": ["notification", "push"] }]` |
| `minSampleSize` | `40` | Minimum reviews in **each** window |
| `minRateIncreasePercentagePoints` | `5` | Minimum rise in the complaint share |
| `minRelativeIncrease` | `1` | `1` means the later rate is at least double the earlier rate. A jump from zero uses the percentage-point rule |
| `minComplaintCount` | `5` | Minimum complaints on the later side |
| `maxComplaintScore` | `3` | Stars at or below this count as complaints when a keyword hits. Higher stars count only when the text also describes a failure |
| `alertOnlyOnChange` | `false` | Skip a repeat alert when both rates stayed within one percentage point of the previous run |
| `outputMode` | `full` | `full`, `alerts-only`, or `changes-only` |
| `appleAppIds` | empty | Optional numeric App Store ids |
| `enableLlm` / `llmApiKey` | off | Optional labels for unmatched low-star reviews, on your own key |
| `stateStoreName` | `app-release-complaint-state` | Named store for review hashes and previous alerts |

At most 12 app-language-country scopes run per execution. Further scopes are reported as `unsupported` and are not charged.

Built-in categories: `login`, `crash`, `payments`, `performance`, `ads`, `ui`, `sync`. English keywords always apply. Spanish, Portuguese, German, and French add a short extra list when the language code matches. Other languages still collect reviews. Add `customCategories`, or turn on the optional model, when the built-in list is the wrong vocabulary.

### What you get

The default dataset is flat, so a webhook, Make, n8n, or a spreadsheet can filter it without unpacking nested objects. Every row has `recordType`, `sourceUrl`, `observedAt`, `coverageStatus`, and `associationNote`.

| `recordType` | When it appears |
| --- | --- |
| `review` | A new or edited review that was delivered. Unchanged reviews are not written again |
| `category-rate` | One row per category, with both counts and both denominators |
| `regression-alert` | The thresholds and sample sizes were met |
| `regression-cleared` | `alertOnlyOnChange` is on and a previous alert no longer meets the rule |
| `scope-summary` | What was collected, which comparison was used, and what was charged |

`coverageStatus` is one of `complete`, `partial`, `empty`, `blocked`, or `unsupported`. Those are different outcomes. A finished run can still be `partial`.

`comparisonBasis` is one of:

- `calendar-halves` — the lookback split into two equal periods
- `release-date` — equal-length periods before and after the date you supplied
- `app-version` — the version you named versus other reviews that carry a version. Reviews with no version are excluded and counted in `versionUnknownReviews`
- `sampled-halves` — the cap was hit before the requested window was covered. Rates describe the older half and the newer half of the sample. **Alerts are withheld**

The run summary is in the default key-value store under `OUTPUT`. It includes counts, `chargedUnits`, `deliveredUnits`, and per-scope coverage. Review baselines live in the named state store, not in `OUTPUT`.

#### Example alert

```json
{
  "recordType": "regression-alert",
  "recordId": "alert:google-play__com.example.app__en__us:login:3-18",
  "source": "google-play",
  "appId": "com.example.app",
  "language": "en",
  "country": "us",
  "category": "login",
  "ratePhrase": "login complaints 3/150 to 18/160",
  "beforeCount": 3,
  "beforeSample": 150,
  "afterCount": 18,
  "afterSample": 160,
  "beforeRate": 0.02,
  "afterRate": 0.1125,
  "direction": "up",
  "regressionDetected": true,
  "alertEmitted": true,
  "comparisonBasis": "calendar-halves",
  "coverageStatus": "complete",
  "windowsComplete": true,
  "examplesText": "Cannot log in after the update || Login fails on the password screen",
  "sourceUrl": "https://play.google.com/store/apps/details?id=com.example.app&hl=en&gl=us",
  "associationNote": "Time-window or version association, not causation. A higher complaint rate after a date or app version does not prove the release caused the complaints."
}
```

A 5-star review that says login is smooth is a mention, not a complaint, so it stays in the denominator and out of the numerator. A 1-star review that matches no keyword is counted in `unclassifiedComplaintCount` on the scope summary so you can see what the lexicon missed.

### Pricing

This Actor uses pay-per-event pricing. You are charged only for a unit it is about to deliver, and there is no separate per-result charge. Each run also has a **$0.005** start event. Default memory is **512 MB** (maximum 1024 MB).

| Event | When it is billed | Price |
| --- | --- | --- |
| `review-processed` | One new review, or one review whose text, stars, or version changed | $0.002 |
| `app-analysis` | One completed app-language-country analysis (Play), or one app-country analysis (App Store) | $0.50 |

An unchanged review is not billed again. A blocked or unusable collection is not billed, and it does not replace a saved baseline. If the run spend limit is reached, delivery stops. Reviews that were not delivered are not marked as done, so a later run can still process them.

| Workload | Events | Cost |
| --- | --- | --- |
| Default first look: WhatsApp, English, United States, 200 new reviews, 1 analysis | 200 × $0.002 + 1 × $0.50 | **$0.90** |
| Same scope the next morning, 30 new reviews | 30 × $0.002 + 1 × $0.50 | **$0.56** |
| Eight apps, one language, weekly, about 2,000 new reviews | 2,000 × $0.002 + 8 × $0.50 | **$8.00** per run |
| Larger month: 20,000 new or edited reviews and 60 analyses | 20,000 × $0.002 + 60 × $0.50 | **$70.00** |

The cap inside one run is 12 scopes and 1,000 reviews each: 12,000 × $0.002 + 12 × $0.50 = **$30** before the platform start event. Set a max total charge on the run if you want a harder ceiling. Optional model calls are billed by your model provider, not by these events.

Local runs and accounts that are not on pay-per-event pricing still receive the dataset. `chargedUnits` stays at zero in that case. `deliveredUnits` shows what would be billed once the events are priced.

### Default input, checked 23 October 2026

`com.whatsapp`, language `en`, country `us`, 7-day lookback, cap 200. The run finished in a few seconds and wrote **208 rows**: 200 reviews, 7 category-rate rows, and 1 scope summary.

Those 200 newest reviews spanned about **2 hours**, so the 7-day window was `partial` and the basis was `sampled-halves`. One rate row was `login complaints 0/100 to 1/100`. **No regression alert was emitted**, because a cut-off sample is not allowed to alert. A second run on the same baseline wrote **8 rows** (the rates and the summary) and did not deliver the 200 reviews again.

That is the honest result for a very high-volume app at this cap. To cover a real release window, raise `maxReviewsPerScope`, narrow the lookback, or pass `releaseDate` / `appVersion` and accept `partial` until the read reaches the earlier side.

### How a scheduled run behaves

Each successful scope stores review ids and content hashes in the named key-value store. The next run re-reads the lookback plus `publicationLagDays`, so a review that shows up late can still be clustered. Edits change the hash and are delivered once as `edited`. A run that is blocked, or that returns nothing while a baseline already exists, leaves the baseline in place.

`alertOnlyOnChange` compares the new rates with the previous alert. A move smaller than one percentage point does not emit another `regression-alert`. If the category no longer qualifies, the dataset gets `regression-cleared`.

### Sources and limits

Google Play reviews come from the public Play batch endpoint, the same call the `google-play-scraper` library wraps. Requests are throttled, retried, and sent with the User-Agent `app-release-complaint-monitor/1.0 (+mailto:plusonecares@gmail.com)`. No browser is used.

Apple is optional. The public customer-review RSS feed is storefront-scoped, about 10 pages, and sometimes returns an empty page in the middle. Empty pages are skipped. The feed is not a language feed: one analysis is charged per Apple app and country, not once per language.

Known limits:

- The Play batch shape can change. A broken response is `blocked`, not an empty product.
- Newest-first collection plus a few hundred reviews does not cover a week of a top app.
- Keywords miss paraphrases and can hit an incidental word. `unclassifiedComplaintCount` and the excerpts are there so a person can check.
- Some Play reviews have no version. They are left out of a version split.
- The storefront country is not the reviewer's location.
- A cleared or withheld alert is still a rate. It is not a statement that the release was fine.
- Platform charge idempotency is short. The state store is what stops a later run from billing the same review hash again.
- Review text in the dataset is an excerpt, capped and redacted. It is for your investigation, not a corpus to republish.

### Optional model

Leave `enableLlm` off and the Actor is fully deterministic. Turn it on and supply `llmApiKey` if you want unmatched reviews at or below `maxComplaintScore` sent to an OpenAI-compatible `chat/completions` endpoint. At most `llmMaxReviews` (default 15) are sent per scope. Results are cached by text hash in the state store. A bad response, a non-HTTPS URL, or a host that resolves to a private or link-local address is skipped, and the keyword labels stand. The key is a secret input and is not written to the dataset.

### Run it

```bash
pip install -r requirements.txt
python3 main.py
```

With no input file, the defaults above are used. To pass input locally, put `INPUT.json` in the working directory or start the run from the Apify console. Tests:

```bash
pip install pytest pytest-asyncio
pytest
```

On Apify, push this directory with the Apify CLI when you are ready. Memory for a normal run is 512 MB.

### Integrations

Filter `recordType` = `regression-alert` if the webhook should page a human. Filter `category-rate` if you want every denominator, including the quiet categories.

- **Webhook.** On the Actor task, add a webhook for the run succeeded event. Point it at your endpoint. Read `ratePhrase`, `examplesText`, `coverageStatus`, and `associationNote`.
- **n8n.** HTTP Request node runs the Actor, then a second node fetches `{{links.apiDefaultDatasetUrl}}/items?fields=recordType,appId,category,ratePhrase,examplesText,coverageStatus`. Keep the items where `recordType` is `regression-alert`.
- **Make.** Use the Apify module to run the Actor and list dataset items. Map `ratePhrase` into the message body.
- **Google Sheets.** The dataset columns are flat. Export the run or connect the Apify Google Sheets integration. `examplesText` is the CSV-friendly form of the excerpts.

A useful schedule is once after you ship, then daily while the release is fresh. Pass the same `releaseDate` or `appVersion` each time so the windows stay comparable. Turn on `alertOnlyOnChange` when the first alert has already been seen.

### FAQ

**Does an alert mean the release caused the complaints?**
No. The note on every row says the figure is a time-window or version association. Use it to decide whether a person should open the excerpts and your own crash or login telemetry.

**Why did the default WhatsApp run not alert?**
Two hundred newest reviews covered about two hours, not seven days. The Actor still returns the rates and labels the coverage `partial`. Alerts wait until both windows are actually covered and the sample minimum is met.

**Why is a quiet category still a row?**
`crash complaints 0/150 to 0/160` tells you the denominator existed and the lexicon found nothing. A missing row would look the same as a failed collection.

**What if Google or Apple returns an error?**
That scope is `blocked` or `unsupported`. Nothing is charged for it. If a baseline already exists, it is kept. If every scope is blocked, the run fails after writing `OUTPUT`, so a schedule does not look successful.

**Can I point the model URL at an internal network?**
No. Only public `https` hosts are accepted. Localhost, link-local, and private addresses are rejected.

**Who do I write to?**
plusonecares@gmail.com. Include the run id, the input (without the model key), and whether `coverageStatus` was `blocked` or `partial`.

# Actor input Schema

## `appIds` (type: `array`):

Package names such as com.whatsapp. One analysis is run for each app, language, and country.

## `languages` (type: `array`):

Google Play language codes, for example en or pt-BR. Apple's public feed is not language-scoped.

## `countries` (type: `array`):

Storefront country codes, for example us or de.

## `lookbackDays` (type: `integer`):

How many days of review timestamps to compare. The default splits that span into two equal windows.

## `maxReviewsPerScope` (type: `integer`):

Newest-first cap. A high-volume app will not cover the full lookback at a few hundred reviews. Incomplete windows do not raise alerts.

## `publicationLagDays` (type: `integer`):

Extra days re-read on every run so a review that appears late can still be seen. Unchanged reviews are not charged again.

## `releaseDate` (type: `string`):

Optional ISO date (YYYY-MM-DD) used as the split for every app that does not have its own entry in releases. The result is a time-window association, not causation.

## `appVersion` (type: `string`):

Optional exact version string. Used only when the store puts that version on the review. Reviews with no version are excluded from the version split.

## `releases` (type: `array`):

Optional overrides. Each item is {"appId", "version", "releasedAt"}.

## `customCategories` (type: `array`):

Optional extra clusters. Each item is {"name": "notifications", "keywords": \["notification", "push"]}.

## `minSampleSize` (type: `integer`):

Denominators below this size never raise an alert. The rate row is still written.

## `minRateIncreasePercentagePoints` (type: `number`):

5 means the complaint share must rise by at least 5 points, such as 2% to 11%.

## `minRelativeIncrease` (type: `number`):

1 means the later rate must be at least double the earlier rate. A jump from zero uses the percentage-point rule only.

## `minComplaintCount` (type: `integer`):

Stops a tiny cluster, such as 1 complaint, from alerting even when the rate jumps.

## `maxComplaintScore` (type: `integer`):

At or below this score, a category keyword counts as a complaint. Higher scores count only when the text also describes a failure.

## `alertOnlyOnChange` (type: `boolean`):

Skip a repeat alert when the before and after rates stayed within one percentage point of the previous run. A cleared alert is reported when the rule no longer holds.

## `outputMode` (type: `string`):

full writes reviews, rates, and alerts. alerts-only omits review rows. changes-only keeps reviews, alerts, and rate rows that regressed.

## `appleAppIds` (type: `array`):

Optional numeric ids from the App Store URL. The public RSS feed returns at most about 500 recent reviews per country and is not a language feed.

## `enableLlm` (type: `boolean`):

Off by default. Keywords still run. The model is used only for low-star reviews that matched nothing, and a failure keeps the keyword result.

## `llmApiKey` (type: `string`):

Your own OpenAI-compatible key. It is not stored in the dataset. Leave empty to stay fully deterministic.

## `llmBaseUrl` (type: `string`):

Optional HTTPS origin, for example https://api.openai.com/v1. Private, loopback, and link-local hosts are rejected.

## `llmModel` (type: `string`):

Passed through to the model endpoint when a key is set.

## `llmMaxReviews` (type: `integer`):

Caps how many unmatched low-star reviews are sent to your model on one run. Results are cached by text hash.

## `stateStoreName` (type: `string`):

Named key-value store that remembers review hashes and previous alerts. A failed collection does not replace a good baseline.

## Actor input object example

```json
{
  "appIds": [
    "com.whatsapp"
  ],
  "languages": [
    "en"
  ],
  "countries": [
    "us"
  ],
  "lookbackDays": 7,
  "maxReviewsPerScope": 200,
  "publicationLagDays": 2,
  "minSampleSize": 40,
  "minRateIncreasePercentagePoints": 5,
  "minRelativeIncrease": 1,
  "minComplaintCount": 5,
  "maxComplaintScore": 3,
  "alertOnlyOnChange": false,
  "outputMode": "full",
  "enableLlm": false,
  "llmModel": "gpt-4o-mini",
  "llmMaxReviews": 15,
  "stateStoreName": "app-release-complaint-state"
}
```

# Actor output Schema

## `dataset` (type: `string`):

Flat rows: reviews, category rates, regression alerts, and one scope summary per app.

## `report` (type: `string`):

Coverage, counts, and charged units for this run.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "appIds": [
        "com.whatsapp"
    ],
    "languages": [
        "en"
    ],
    "countries": [
        "us"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("plusonets/app-release-complaint-monitor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "appIds": ["com.whatsapp"],
    "languages": ["en"],
    "countries": ["us"],
}

# Run the Actor and wait for it to finish
run = client.actor("plusonets/app-release-complaint-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "appIds": [
    "com.whatsapp"
  ],
  "languages": [
    "en"
  ],
  "countries": [
    "us"
  ]
}' |
apify call plusonets/app-release-complaint-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,plusonets/app-release-complaint-monitor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/Y3VmaUvuXx4QAHjcQ/builds/SWtm8a631iaIor44O/openapi.json
