Subversion Repositories SmartDukaan

Rev

Rev 37472 | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
37472 amit 1
package com.smartdukaan.cron.monitored;
2
 
37565 amit 3
import java.time.LocalDate;
37472 amit 4
import java.util.Map;
5
import java.util.concurrent.ConcurrentHashMap;
6
import java.util.concurrent.atomic.AtomicInteger;
7
import java.util.concurrent.atomic.AtomicLong;
8
 
9
import io.micrometer.core.instrument.Gauge;
10
import io.micrometer.core.instrument.MeterRegistry;
11
import io.micrometer.core.instrument.Tags;
12
import org.apache.logging.log4j.LogManager;
13
import org.apache.logging.log4j.Logger;
14
import org.springframework.stereotype.Component;
15
 
16
/**
17
 * The per-brand IMEI activation funnel, on /actuator/prometheus.
18
 *
19
 * Until now the only way to know whether a brand was working was to grep free text out of
20
 * a 1.2GB cron.log and reconstruct the funnel with awk. That is how two outages stayed
21
 * invisible: oppo returned an empty map for a week (same 10 imeis recycling every 5
22
 * minutes, nothing written), and the vivo captcha solver was dead for 46 days. Both had
23
 * the same shape and neither raised anything.
24
 *
25
 * The funnel is deliberately staged, because WHICH stage stalls says what broke:
26
 *
27
 *   due        -> pool the pass snapshotted. 0 with a large backlog = the query is wrong.
28
 *   churned    -> imeis the pass actually reached. Short of `due` = the pass ran out of
29
 *                 day. This is the capacity signal.
30
 *   captcha_shown  -> got as far as a rendered captcha. Collapses when the far end stops
31
 *                 serving the widget -- realme's rate-limit signature, which looks like
32
 *                 nothing else in the funnel.
33
 *   captcha_solved -> we broke it. shown/solved is the solver's real accuracy.
34
 *   answered   -> the portal gave a verdict. THIS is what bumps createTimestamp and lets
35
 *                 an imei leave the queue, so answered==0 while churned>0 is the precise
36
 *                 signature of both historical outages.
37
 *   dates_found-> an activation date came back. Always a fraction of `answered`: most
38
 *                 handsets legitimately are not activated yet, so this is a business
39
 *                 number, not a health one. Do not alert on it alone.
40
 *
41
 * Rates are deliberately NOT published. solved/shown and dates/answered are one division
42
 * in PromQL and storing them would freeze the numerator and denominator apart.
43
 *
37565 amit 44
 * -1 means "no day has been cleared since this JVM started", so a scrape before the first
45
 * tick is distinguishable from a genuine zero -- same convention as {@link BalanceGauges}.
46
 *
47
 * ⚠ The gauges are registered lazily, on the first tick that names a brand. After a
48
 * restart the whole imei_activation_* family is therefore ABSENT from /actuator/prometheus
49
 * rather than reading -1, until each lane takes its first turn. Under the old midnight
50
 * cron that blind window could last most of a day; under a 20-second tick it is seconds.
37472 amit 51
 */
52
@Component
53
public class ImeiActivationGauges {
54
 
55
    private static final Logger LOGGER = LogManager.getLogger(ImeiActivationGauges.class);
56
 
57
    private static final int NOT_RUN_YET = -1;
58
 
59
    private final MeterRegistry meterRegistry;
60
    private final Map<String, BrandFunnel> byBrand = new ConcurrentHashMap<>();
61
 
62
    public ImeiActivationGauges(MeterRegistry meterRegistry) {
63
        this.meterRegistry = meterRegistry;
64
    }
65
 
37565 amit 66
    /**
67
     * True while this brand has not yet opened today's funnel.
68
     *
69
     * The caller uses it to decide how much pool to fetch: on the day's first tick it
70
     * needs the whole thing to have a denominator, on every later tick it needs only the
71
     * next chunk. Asking here keeps that decision in one place rather than duplicating a
72
     * date field per lane.
73
     */
74
    public boolean needsDayStart(String brand) {
75
        return !LocalDate.now().equals(funnel(brand).day);
76
    }
77
 
78
    /**
79
     * Open a brand's funnel for today, with the pool size measured on the first tick.
80
     *
81
     * The unit used to be a pass -- one bounded run that began and ended inside a single
82
     * call. Ticks have no such boundary, so the DAY is the unit now: `due` is what was
83
     * outstanding at the first tick after midnight, and everything else accumulates
84
     * across the day's ticks until the pool comes back empty.
85
     *
86
     * Truncation is detected HERE rather than at the end of a run, because a day that
87
     * ran out of hours never reaches endDay at all -- it just stops. Rolling over with
88
     * yesterday still open is exactly that case, and it is the line worth alerting on.
89
     */
90
    public void beginDay(String brand, int due) {
37472 amit 91
        BrandFunnel f = funnel(brand);
37565 amit 92
        if (f.day != null && !f.finished) {
93
            LOGGER.warn("[{}] {} ended without clearing: {} of {} imeis churned, the tail rolls into today",
94
                    brand, f.day, f.churned.get(), f.due.get());
95
        }
37472 amit 96
        f.reset(due);
37565 amit 97
        f.day = LocalDate.now();
98
        f.finished = false;
99
        LOGGER.info("[{}] day begin: {} imeis due", brand, due);
37472 amit 100
    }
101
 
102
    /** A captcha widget rendered and is ready to be attacked. */
103
    public void captchaShown(String brand) {
104
        funnel(brand).captchaShown.incrementAndGet();
105
    }
106
 
107
    /** A captcha was broken. */
108
    public void captchaSolved(String brand) {
109
        funnel(brand).captchaSolved.incrementAndGet();
110
    }
111
 
112
    /**
113
     * The portal returned a verdict for one imei. {@code dateFound} is false for a
114
     * legitimate "not activated yet" -- that is still an answer, and still lets the row
115
     * leave the queue.
116
     */
117
    public void answered(String brand, boolean dateFound) {
118
        BrandFunnel f = funnel(brand);
119
        f.answered.incrementAndGet();
120
        if (dateFound) {
121
            f.datesFound.incrementAndGet();
122
        }
123
    }
124
 
125
    /** One imei was consumed from the pass, whatever the outcome. */
126
    public void churned(String brand, int count) {
127
        funnel(brand).churned.addAndGet(count);
128
    }
129
 
130
    /** An exception escaped somewhere in this brand's pass. */
131
    public void error(String brand) {
132
        funnel(brand).errors.incrementAndGet();
133
    }
134
 
135
    /**
37565 amit 136
     * Called the first time a brand's turn comes back with nothing due, which under a
137
     * tick model is what "the day's work is cleared" means. Stamps the duration and the
138
     * completion time, and logs the one line worth grepping for.
139
     *
140
     * Idempotent by design: every later tick that day also finds an empty pool and calls
141
     * this, and must stay silent. Silence is the point -- at a 20-second cadence a log
142
     * line per idle tick is 4,320 lines a day per brand on a log that is already 1.8GB.
143
     *
144
     * lastFinishEpoch therefore becomes a genuine completion clock. It read -1 for three
145
     * days straight under the old model, which said only "no pass has finished since this
146
     * JVM started" and could not distinguish a truncated pass from a fresh restart.
37472 amit 147
     */
37565 amit 148
    public void endDay(String brand) {
37472 amit 149
        BrandFunnel f = funnel(brand);
37565 amit 150
        if (!LocalDate.now().equals(f.day) || f.finished) {
151
            return;
152
        }
153
        f.finished = true;
37472 amit 154
        long seconds = (System.currentTimeMillis() - f.startedAtMillis) / 1000;
155
        f.runSeconds.set(seconds);
156
        f.lastFinishEpoch.set(System.currentTimeMillis() / 1000);
157
 
158
        int shown = f.captchaShown.get();
159
        int solved = f.captchaSolved.get();
160
        int answered = f.answered.get();
37565 amit 161
        LOGGER.info("[{}] day cleared: due={} churned={} captcha {}/{} ({}%) answered={} dates={} errors={} in {}s",
37472 amit 162
                brand, f.due.get(), f.churned.get(), solved, shown, percent(solved, shown),
163
                answered, f.datesFound.get(), f.errors.get(), seconds);
164
 
165
        if (f.churned.get() > 0 && answered == 0) {
37565 amit 166
            LOGGER.error("[{}] answered NOTHING across {} imeis -- the brand is down, not merely unlucky",
37472 amit 167
                    brand, f.churned.get());
168
        }
37565 amit 169
        // churned short of due is normal now rather than a truncation: the pool is
170
        // re-queried every tick, so anything another writer filled in mid-day (carlcare
171
        // and the DCR pull both write rows) leaves without this lane ever touching it.
172
        // Real truncation is the day that never gets here at all, caught in beginDay.
37472 amit 173
    }
174
 
175
    private static int percent(int part, int whole) {
176
        return whole == 0 ? 0 : (int) Math.round(100.0 * part / whole);
177
    }
178
 
179
    private BrandFunnel funnel(String brand) {
180
        return byBrand.computeIfAbsent(brand, b -> new BrandFunnel(meterRegistry, b));
181
    }
182
 
183
    /**
184
     * One brand's counters, registered against the meter registry the first time that
37565 amit 185
     * brand is seen. Gauges hold the CURRENT DAY's values rather than lifetime totals:
186
     * the question being asked is "how far has today got", and a lifetime counter cannot
187
     * answer it. They climb across the day's ticks and reset at the first tick after
188
     * midnight.
37472 amit 189
     */
190
    private static final class BrandFunnel {
191
 
192
        private final AtomicInteger due = new AtomicInteger(NOT_RUN_YET);
193
        private final AtomicInteger churned = new AtomicInteger(NOT_RUN_YET);
194
        private final AtomicInteger captchaShown = new AtomicInteger(NOT_RUN_YET);
195
        private final AtomicInteger captchaSolved = new AtomicInteger(NOT_RUN_YET);
196
        private final AtomicInteger answered = new AtomicInteger(NOT_RUN_YET);
197
        private final AtomicInteger datesFound = new AtomicInteger(NOT_RUN_YET);
198
        private final AtomicInteger errors = new AtomicInteger(NOT_RUN_YET);
199
        private final AtomicLong runSeconds = new AtomicLong(NOT_RUN_YET);
200
        private final AtomicLong lastFinishEpoch = new AtomicLong(NOT_RUN_YET);
201
 
202
        private volatile long startedAtMillis = System.currentTimeMillis();
203
 
37565 amit 204
        /** The day this funnel is currently accumulating; null until the first tick ever. */
205
        private volatile LocalDate day;
206
 
207
        /** Whether that day's pool has already come back empty. Keeps endDay silent after the first. */
208
        private volatile boolean finished;
209
 
37472 amit 210
        BrandFunnel(MeterRegistry registry, String brand) {
211
            Tags tags = Tags.of("brand", brand.toLowerCase());
212
            gauge(registry, "imei_activation_due", tags, due,
37565 amit 213
                    "Imeis outstanding at the first tick after midnight; -1 = no tick yet");
37472 amit 214
            gauge(registry, "imei_activation_churned", tags, churned,
37565 amit 215
                    "Imeis today's ticks have reached so far; catches up to due when the day clears");
37472 amit 216
            gauge(registry, "imei_activation_captcha_shown", tags, captchaShown,
217
                    "Captchas that rendered; collapses when the far end stops serving the widget");
218
            gauge(registry, "imei_activation_captcha_solved", tags, captchaSolved,
219
                    "Captchas broken; divide by captcha_shown for the solver's accuracy");
220
            gauge(registry, "imei_activation_answered", tags, answered,
221
                    "Imeis the portal gave a verdict for; this is what lets a row leave the queue");
222
            gauge(registry, "imei_activation_dates_found", tags, datesFound,
223
                    "Activation dates obtained; a fraction of answered by nature, not a health signal");
224
            gauge(registry, "imei_activation_errors", tags, errors,
225
                    "Exceptions during the last pass for this brand");
226
            Gauge.builder("imei_activation_run_seconds", runSeconds, AtomicLong::get)
227
                    .tags(tags)
228
                    .description("Wall-clock seconds of the last pass; -1 = no pass yet")
229
                    .register(registry);
230
            Gauge.builder("imei_activation_last_finish_epoch_seconds", lastFinishEpoch, AtomicLong::get)
231
                    .tags(tags)
232
                    .description("Unix time the last pass finished; staleness alerts key off this")
233
                    .register(registry);
234
        }
235
 
236
        private static void gauge(MeterRegistry registry, String name, Tags tags,
237
                                  AtomicInteger value, String description) {
238
            Gauge.builder(name, value, AtomicInteger::get)
239
                    .tags(tags)
240
                    .description(description)
241
                    .register(registry);
242
        }
243
 
244
        void reset(int dueCount) {
245
            startedAtMillis = System.currentTimeMillis();
246
            due.set(dueCount);
247
            churned.set(0);
248
            captchaShown.set(0);
249
            captchaSolved.set(0);
250
            answered.set(0);
251
            datesFound.set(0);
252
            errors.set(0);
253
        }
254
    }
255
}