Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/insights-metric-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Insights observation contract correction

Companion server PR: https://github.com/reactnativecn/pushy-go/pull/17

## Decisions

The page describes best-effort observations, not an exactly-once update ledger. Delete coverage = retained activation UUIDs / today's check UUIDs and retained activation/download conversion. Do not cap them at 100% or substitute another unrelated denominator. Retained device observations are approximate positive counts, with missing/expired values shown unavailable; key lifetime is not permanent all-time or rolling-window coverage.

Version events are independent reports and target options. Main and experiment can coexist in one response; an experiment offer is not client selection. Patch failure followed by full recovery can generate both failure and success. Display each event separately, and label report shares with their numerator, denominator and sample size. Rollback classification is explicitly rollback-only, never overall health. Low samples are visible; download/Patch failures remain separate columns. The 1%/5% rollback thresholds and minimum sample count are shared with the service-status panel in `src/constants/metric-thresholds.ts`; rollback labels use the shared translation-key map and precomputed row values.

Traffic and failures keep their business-calendar window; historical version events keep their UTC-calendar window. Those UTC daily rollups cannot be shifted losslessly by eight hours. Each section shows the server's exact inclusive/exclusive bounds and last successful UI refresh. Legacy servers have an explicit timezone/availability warning. UI refresh is 60 seconds, not the writer's one-second flush interval. True unified historical windows need a separate collection/retention migration, not a frontend date-label change.

Both request/device means exclude the current partial day, include known valid zeroes and exclude explicitly unavailable values. A device count of zero without availability metadata is ambiguous and is excluded; a zero with `dauStatus: observed` is valid. Positive legacy UUID observations remain usable. Their actual included/eligible day counts are shown. Requests, reports, UUID estimates and people are not interchangeable.

Request-based ratios use the same available request days for numerator and denominator. Days with unavailable or invalid request totals do not contribute hit outcomes, native-package request counts or refused-package details. Independently collected hourly, host, carrier and device observations are retained in their own series. An hourly chart is shown according to its own nonzero observations, not according to the request total. Package request counts/shares with no usable request population are unavailable, not fabricated zeroes.

The server summary is computed before top-50 truncation and includes unattributed event subsets. The console uses it for app-wide totals; legacy responses are labelled returned-version subtotals. Table filters are distinct from the app summary. Package-filtered detail hides cumulative observations because there is no package dimension. The version glance sorts returned candidates by offered targets plus all client report counts, with hash as a deterministic tie-breaker, before taking five entries. It never mutates cached API rows or promises to include versions omitted by the server.

Native package request share is not installation share. Device peaks include only available observations. The page consumes the server's nullable device values, partial/expired/unavailable status, tracking-limit marker and separate 14-day device retention (requests retain 35 days). A missing device estimate is never a reason to infer the package is unused.

No-update includes already-current and no bound version. Paused/restricted can be app, package or quota. Expired identifies a native package. Existing aggregate data cannot establish a more specific reason. All unrecognized failure reasons, including legacy free-text prefixes and future categories, are normalized to a single `other` identity before grouping, preserving event-type and version subtotals.

Failure breakdown availability is evaluated before version filtering. Explicitly unavailable buckets are excluded from every dimension even when leftover numeric arrays are present. An explicitly observed empty bucket is usable; a legacy empty bucket is not evidence of zero failures. Legacy buckets with positive report evidence remain usable with a metadata warning. The UI shows available/total/unavailable day counts; an entirely unavailable window renders unavailable, whereas an observed empty filter states only that no reports were observed in that filter, not that the application is healthy.

## Compatibility and rollout

Deploy pushy-go #17 first, then this console. All server fields are additive; the console continues to operate with old payloads and labels their limitations. No billing, rollout decisions, auto-pause rules, client protocol, retention TTL, production data or schema is mutated. Existing realtime series, release insights and region sections retain their independent time controls.

Metric-contract translations live directly in the canonical `src/i18n/locales/en.json` and `zh-CN.json` catalogs. The duplicate runtime override file has been removed. Runtime registration and locale validation import the same side-effect-free `src/i18n/resources.ts` catalog. Tests retain base JSON key parity, validate bilingual key parity and static source references, reject plain-text rendering of tagged translations, and assert that runtime resources are the canonical JSON objects.

## Verification

Unit regressions cover zero days, unavailable values, partial today, supplied business date, independently retained UUID sets, package filters, old/new payloads, top-N summaries, unlinked patch recovery, sample thresholds and old failure labels. Review regressions additionally cover matching request populations, invalid denominators, legacy versus explicit UUID zeroes, all/mixed/legacy breakdown availability, unknown-category coalescing, non-mutating ranking and inclusive rollback threshold boundaries.

Render tests cover rollback-only wording, hidden out-of-scope retained metrics, the former 250% scenario, legacy totals, stale windows and package observation states. They also distinguish entirely unavailable failures from explicitly observed empty buckets, preserve observed failures in mixed windows, and display legacy availability warnings in Chinese.

The existing CI gates remain unchanged: typecheck, lint, tests, production build and bundle-size limits. One-off repair helpers and workflows used to apply and validate the edits have been removed from the final branch.

## Not fabricated by this correction

Current version device share, linked-attempt upgrade conversion, first-device activation latency and exact gray participation need their own observation sets or client-attempt correlation. This change removes unsupported claims instead of inventing those measurements.
8 changes: 4 additions & 4 deletions src/constants/i18n-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ export const DEPS_VIOLATION_MESSAGE_KEY: Record<
rnu_downgrade: 'bind_package.deps_rnu_downgrade',
};

/** 版本漏斗的健康标签(回滚率阈值判定,null 表示样本不足不判定)。 */
/** 回滚报告占比标签(null 表示样本不足,不代表整体健康)。 */
export const FUNNEL_HEALTH_LABEL_KEY: Record<
NonNullable<FunnelHealth>,
string
> = {
healthy: 'app_insights.health_healthy',
warning: 'app_insights.health_warning',
critical: 'app_insights.health_critical',
healthy: 'app_insights.rollback_low',
warning: 'app_insights.rollback_warning',
critical: 'app_insights.rollback_high',
};
5 changes: 5 additions & 0 deletions src/constants/metric-thresholds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Shared by app insights and the service-status version report panel.
// These thresholds classify rollback report shares, not overall health.
export const CRITICAL_ROLLBACK = 0.05;
export const WARNING_ROLLBACK = 0.01;
export const MIN_EVENT_SAMPLES = 10;
9 changes: 9 additions & 0 deletions src/i18n/canonical-resources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expect, test } from 'bun:test';
import en from './locales/en.json';
import zhCN from './locales/zh-CN.json';
import { resources } from './resources';

test('runtime translations use canonical JSON without duplicate overrides', () => {
expect(resources.en.translation).toBe(en);
expect(resources['zh-CN'].translation).toBe(zhCN);
});
8 changes: 2 additions & 6 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import zhCN from './locales/zh-CN.json';
import { resources } from './resources';

i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
'zh-CN': { translation: zhCN },
},
resources,
fallbackLng: 'zh-CN',
interpolation: {
escapeValue: false,
Expand Down
45 changes: 19 additions & 26 deletions src/i18n/locales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ import { describe, expect, it } from 'bun:test';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resources } from './resources';

// 两类 i18n 事故 CI 都看不见,因为 t() 的返回值类型上就是 string:
// 1. 只给一个语言包加了 key —— 另一个语言回退成英文/中文原文;
// 2. 两个语言包都没加 —— 界面直接显示 'nav.automation' 这样的原始 key;
// 3. 文案里带 <strong> 之类的标签,却用 t() 渲染 —— 标签被原样显示。
// 下面三个测试分别挡住它们。

// Validate both the base JSON catalogs and the effective runtime resources.
// Runtime overlays must not bypass key parity, missing-key or markup checks.
const HERE = fileURLToPath(new URL('.', import.meta.url));
const LOCALES_DIR = join(HERE, 'locales');
const SRC_DIR = join(HERE, '..');
Expand Down Expand Up @@ -42,8 +39,7 @@ function sourceFiles(dir: string): string[] {

/**
* 静态可解析的 key:t('a.b') 与 <Trans i18nKey="a.b">。
* t(`a.b_${x}`) 这类模板字面量无法静态判定,由 locale 平价测试兜底
* ——只要两个语言包的键集一致,动态族就不会只在一边存在。
* t(`a.b_${x}`) 这类模板字面量无法静态判定,由 locale 平价测试兜底。
*/
function staticKeysIn(source: string): string[] {
const keys: string[] = [];
Expand Down Expand Up @@ -72,62 +68,59 @@ function valueAt(locale: Json, path: string): string | undefined {
return typeof found === 'string' ? found : undefined;
}

const en = loadLocale('en.json');
const zh = loadLocale('zh-CN.json');
const en = resources.en.translation;
const zh = resources['zh-CN'].translation;

describe('i18n locales', () => {
it('en 与 zh-CN 的键集完全一致', () => {
it('base JSON catalogs keep identical key sets', () => {
expect(leafKeys(loadLocale('en.json')).sort()).toEqual(
leafKeys(loadLocale('zh-CN.json')).sort(),
);
});

it('en 与 zh-CN 的运行时键集完全一致', () => {
const enKeys = new Set(leafKeys(en));
const zhKeys = new Set(leafKeys(zh));

const missingInZh = [...enKeys].filter((key) => !zhKeys.has(key)).sort();
const missingInEn = [...zhKeys].filter((key) => !enKeys.has(key)).sort();

expect({ missingInZh, missingInEn }).toEqual({
missingInZh: [],
missingInEn: [],
});
});

it('源码里静态引用的 key 在两个语言包里都存在', () => {
it('源码里静态引用的 key 在两个运行时语言包里都存在', () => {
const enKeys = new Set(leafKeys(en));
const zhKeys = new Set(leafKeys(zh));

const referenced = new Set(
sourceFiles(SRC_DIR).flatMap((file) =>
staticKeysIn(readFileSync(file, 'utf-8')),
),
);

// 只校验看起来像 i18n key 的引用(带点号的命名空间路径),
// 避免把恰好叫 t() 的其他调用误判成翻译。
const namespaced = [...referenced].filter((key) => key.includes('.'));
const missing = namespaced
.filter((key) => !enKeys.has(key) || !zhKeys.has(key))
.sort();

expect(missing).toEqual([]);
});

it('带标签的文案不能走 t(),必须用 <Trans> 渲染', () => {
// t() 返回的是字符串,React 会把 '<strong>原生代码</strong>' 原样显示出来。
// 这类文案只能交给 <Trans components={{ strong: <strong /> }} />。
const markup = leafKeys(en).filter((key) =>
/<[a-z][a-z0-9]*>/i.test(valueAt(en, key) ?? ''),
const markup = [en, zh].flatMap((locale) =>
leafKeys(locale).filter((key) =>
/<[a-z][a-z0-9]*>/i.test(valueAt(locale, key) ?? ''),
),
);

const sources = sourceFiles(SRC_DIR).map((file) =>
readFileSync(file, 'utf-8'),
);
const renderedAsPlainText = markup
const renderedAsPlainText = [...new Set(markup)]
.filter((key) =>
sources.some(
(source) =>
source.includes(`t('${key}')`) || source.includes(`t("${key}")`),
),
)
.sort();

expect(renderedAsPlainText).toEqual([]);
});
});
Loading
Loading