reis-mobile 0.7.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +100 -26
- package/agents/android-architect.md +3 -3
- package/agents/android-performance-engineer.md +1 -1
- package/agents/flutter-architect.md +3 -3
- package/agents/flutter-performance-engineer.md +1 -1
- package/agents/ios-architect.md +3 -3
- package/agents/ios-performance-engineer.md +1 -1
- package/agents/kmp-architect.md +3 -3
- package/agents/kmp-performance-engineer.md +1 -1
- package/agents/lead-mobile.md +3 -0
- package/agents/mobile-release-engineer.md +3 -3
- package/agents/mobile-staff-engineer.md +1 -1
- package/agents/rn-architect.md +3 -3
- package/agents/rn-performance-engineer.md +1 -1
- package/bin/reis-mobile.mjs +123 -1
- package/commands/consult.md +95 -0
- package/commands/debate.md +15 -11
- package/commands/reis-mobile.md +7 -2
- package/commands/review.md +2 -0
- package/core/config/project.mjs +1 -1
- package/core/diagnostics/doctor.mjs +23 -1
- package/core/providers/catalog.mjs +168 -0
- package/core/providers/consult.mjs +220 -0
- package/core/providers/providers.mjs +156 -0
- package/core/router/intents.mjs +15 -0
- package/package.json +1 -1
- package/skills/mobile-accessibility/SKILL.md +3 -0
- package/skills/mobile-accessibility/references/localization.md +77 -0
- package/skills/mobile-observability/SKILL.md +137 -0
- package/skills/mobile-observability/references/android.md +105 -0
- package/skills/mobile-observability/references/flutter.md +86 -0
- package/skills/mobile-observability/references/ios.md +88 -0
- package/skills/mobile-observability/references/kotlin-multiplatform.md +76 -0
- package/skills/mobile-observability/references/react-native.md +62 -0
- package/skills/mobile-offline-sync/SKILL.md +132 -0
- package/skills/mobile-offline-sync/references/android.md +123 -0
- package/skills/mobile-offline-sync/references/flutter.md +103 -0
- package/skills/mobile-offline-sync/references/ios.md +103 -0
- package/skills/mobile-offline-sync/references/kotlin-multiplatform.md +114 -0
- package/skills/mobile-offline-sync/references/react-native.md +108 -0
- package/skills/mobile-performance/SKILL.md +88 -0
- package/skills/mobile-performance/references/android.md +96 -0
- package/skills/mobile-performance/references/background.md +87 -0
- package/skills/mobile-performance/references/flutter.md +101 -0
- package/skills/mobile-performance/references/ios.md +82 -0
- package/skills/mobile-performance/references/kotlin-multiplatform.md +62 -0
- package/skills/mobile-performance/references/react-native.md +85 -0
- package/skills/mobile-release/SKILL.md +2 -1
- package/skills/mobile-release/references/billing.md +92 -0
- package/skills/mobile-security/SKILL.md +3 -0
- package/skills/mobile-security/references/auth.md +92 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { redactSecrets } from '../security/redact.mjs';
|
|
7
|
+
import { findProvider } from './catalog.mjs';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
|
+
const MAX_OUTPUT_BYTES = 2_000_000;
|
|
11
|
+
const ERROR_BODY_CHARS = 300;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Asks each provider the same question, in parallel, and returns one result per requested
|
|
15
|
+
* provider.
|
|
16
|
+
*
|
|
17
|
+
* The core makes the call instead of handing the model a command to run: a credential must
|
|
18
|
+
* never reach a transcript or a tool log. The prompt is redacted on the way out — a
|
|
19
|
+
* third-party provider is exactly the boundary `redactSecrets` exists for — and every answer
|
|
20
|
+
* is redacted on the way in, because a provider can echo back what it was given.
|
|
21
|
+
*
|
|
22
|
+
* Statuses: `ok`, `unavailable` (the provider cannot be reached at all), `no-model`,
|
|
23
|
+
* `timeout`, `error`.
|
|
24
|
+
*/
|
|
25
|
+
export async function consult({
|
|
26
|
+
prompt,
|
|
27
|
+
providers,
|
|
28
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
29
|
+
env = process.env,
|
|
30
|
+
fetchImpl = globalThis.fetch,
|
|
31
|
+
runCommand = spawnCommand,
|
|
32
|
+
} = {}) {
|
|
33
|
+
const text = redactSecrets(String(prompt ?? ''));
|
|
34
|
+
if (!text.trim()) throw new Error('consult needs a prompt. Pass it as an argument, with --file, or on stdin.');
|
|
35
|
+
|
|
36
|
+
const results = await Promise.all(
|
|
37
|
+
providers.map((resolved) => askOne({ resolved, prompt: text, timeoutMs, env, fetchImpl, runCommand })),
|
|
38
|
+
);
|
|
39
|
+
return {
|
|
40
|
+
prompt: { chars: text.length, redacted: text !== String(prompt ?? '') },
|
|
41
|
+
timeoutMs,
|
|
42
|
+
results,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function askOne({ resolved, prompt, timeoutMs, env, fetchImpl, runCommand }) {
|
|
47
|
+
const base = {
|
|
48
|
+
provider: resolved.id,
|
|
49
|
+
label: resolved.label,
|
|
50
|
+
kind: resolved.kind,
|
|
51
|
+
model: resolved.model,
|
|
52
|
+
status: 'error',
|
|
53
|
+
text: '',
|
|
54
|
+
error: null,
|
|
55
|
+
durationMs: 0,
|
|
56
|
+
};
|
|
57
|
+
if (!resolved.selectable) return { ...base, status: 'unavailable', error: resolved.detail };
|
|
58
|
+
if (resolved.needsModel && !resolved.model) {
|
|
59
|
+
const provider = findProvider(resolved.id);
|
|
60
|
+
return {
|
|
61
|
+
...base,
|
|
62
|
+
status: 'no-model',
|
|
63
|
+
error: `no model configured. Set ${provider.modelEnv}, or providers.${resolved.id}.model in the config file, or pass --model. Run \`reis-mobile providers --models --providers ${resolved.id}\` to see what it serves.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const started = Date.now();
|
|
68
|
+
const outcome = await (resolved.kind === 'cli'
|
|
69
|
+
? askCli({ resolved, prompt, timeoutMs, env, runCommand })
|
|
70
|
+
: askHttp({ resolved, prompt, timeoutMs, env, fetchImpl }));
|
|
71
|
+
return { ...base, ...outcome, durationMs: Date.now() - started };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function askHttp({ resolved, prompt, timeoutMs, env, fetchImpl }) {
|
|
75
|
+
const provider = findProvider(resolved.id);
|
|
76
|
+
const { url, init } = provider.chat({ prompt, model: resolved.model, env });
|
|
77
|
+
const response = await withTimeout(timeoutMs, (signal) => fetchImpl(url, { ...init, signal }));
|
|
78
|
+
if (response.timedOut) return { status: 'timeout', error: `no answer in ${timeoutMs} ms` };
|
|
79
|
+
if (response.failed) return { status: 'error', error: safe(response.error) };
|
|
80
|
+
|
|
81
|
+
if (!response.value.ok) {
|
|
82
|
+
const body = await readBody(response.value);
|
|
83
|
+
return { status: 'error', error: safe(`HTTP ${response.value.status}${body ? `: ${body.slice(0, ERROR_BODY_CHARS)}` : ''}`) };
|
|
84
|
+
}
|
|
85
|
+
let payload;
|
|
86
|
+
try {
|
|
87
|
+
payload = await response.value.json();
|
|
88
|
+
} catch {
|
|
89
|
+
return { status: 'error', error: 'the answer was not JSON' };
|
|
90
|
+
}
|
|
91
|
+
const text = safe(provider.readChat(payload));
|
|
92
|
+
return text.trim() ? { status: 'ok', text } : { status: 'error', error: 'the provider returned no text' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* CLI providers run in a throwaway directory. `codex exec --full-auto` and `gemini
|
|
97
|
+
* --approval-mode yolo` may write files, and the briefing already carries every file they
|
|
98
|
+
* need to read, so there is no reason to give them the user's repository as a workspace.
|
|
99
|
+
*/
|
|
100
|
+
async function askCli({ resolved, prompt, timeoutMs, env, runCommand }) {
|
|
101
|
+
const provider = findProvider(resolved.id);
|
|
102
|
+
const workdir = await mkdtemp(join(tmpdir(), 'reis-mobile-consult-'));
|
|
103
|
+
try {
|
|
104
|
+
const result = await runCommand({
|
|
105
|
+
command: provider.command,
|
|
106
|
+
args: provider.argv({ prompt }),
|
|
107
|
+
stdin: provider.stdin({ prompt }),
|
|
108
|
+
cwd: workdir,
|
|
109
|
+
timeoutMs,
|
|
110
|
+
env,
|
|
111
|
+
});
|
|
112
|
+
if (result.status === 'timeout') return { status: 'timeout', error: `no answer in ${timeoutMs} ms` };
|
|
113
|
+
if (result.status === 'error') return { status: 'error', error: safe(result.error) };
|
|
114
|
+
const text = safe(result.stdout).trim();
|
|
115
|
+
return text ? { status: 'ok', text } : { status: 'error', error: 'the provider returned no text' };
|
|
116
|
+
} finally {
|
|
117
|
+
await rm(workdir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What each selected provider says it serves, so reis-mobile never invents a model id. */
|
|
122
|
+
export async function listProviderModels({ providers, env = process.env, fetchImpl = globalThis.fetch, timeoutMs = 30_000 } = {}) {
|
|
123
|
+
return Promise.all(
|
|
124
|
+
providers.map(async (resolved) => {
|
|
125
|
+
const base = { provider: resolved.id, label: resolved.label, kind: resolved.kind, models: [], status: 'error', error: null };
|
|
126
|
+
if (resolved.kind === 'cli') return { ...base, status: 'unsupported', error: 'a CLI provider uses the model it is configured with' };
|
|
127
|
+
if (!resolved.selectable) return { ...base, status: 'unavailable', error: resolved.detail };
|
|
128
|
+
|
|
129
|
+
const provider = findProvider(resolved.id);
|
|
130
|
+
const { url, init } = provider.catalog({ env });
|
|
131
|
+
const response = await withTimeout(timeoutMs, (signal) => fetchImpl(url, { ...init, signal }));
|
|
132
|
+
if (response.timedOut) return { ...base, status: 'timeout', error: `no answer in ${timeoutMs} ms` };
|
|
133
|
+
if (response.failed) return { ...base, status: 'error', error: safe(response.error) };
|
|
134
|
+
if (!response.value.ok) return { ...base, status: 'error', error: safe(`HTTP ${response.value.status}`) };
|
|
135
|
+
try {
|
|
136
|
+
return { ...base, status: 'ok', models: provider.readCatalog(await response.value.json()) };
|
|
137
|
+
} catch {
|
|
138
|
+
return { ...base, status: 'error', error: 'the answer was not JSON' };
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function withTimeout(timeoutMs, call) {
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
147
|
+
try {
|
|
148
|
+
return { value: await call(controller.signal) };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return controller.signal.aborted ? { timedOut: true } : { failed: true, error: error.message };
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readBody(response) {
|
|
157
|
+
try {
|
|
158
|
+
return (await response.text()).trim().replace(/\s+/g, ' ');
|
|
159
|
+
} catch {
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Collects stdout instead of using execFile, which cannot write to stdin. */
|
|
165
|
+
export function spawnCommand({ command, args, stdin, cwd, timeoutMs, env }) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
let child;
|
|
168
|
+
try {
|
|
169
|
+
child = spawn(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
resolve({ status: 'error', error: error.message });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
let stdout = '';
|
|
175
|
+
let stderr = '';
|
|
176
|
+
let timedOut = false;
|
|
177
|
+
const timer = setTimeout(() => {
|
|
178
|
+
timedOut = true;
|
|
179
|
+
child.kill('SIGKILL');
|
|
180
|
+
}, timeoutMs);
|
|
181
|
+
|
|
182
|
+
child.stdout.on('data', (chunk) => {
|
|
183
|
+
if (stdout.length < MAX_OUTPUT_BYTES) stdout += chunk;
|
|
184
|
+
});
|
|
185
|
+
child.stderr.on('data', (chunk) => {
|
|
186
|
+
if (stderr.length < MAX_OUTPUT_BYTES) stderr += chunk;
|
|
187
|
+
});
|
|
188
|
+
child.stdin.on('error', () => {
|
|
189
|
+
// The provider closed stdin before reading the prompt; the exit code reports it.
|
|
190
|
+
});
|
|
191
|
+
child.on('error', (error) => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
resolve({ status: 'error', error: error.code === 'ENOENT' ? `${command} not found in PATH` : error.message });
|
|
194
|
+
});
|
|
195
|
+
child.on('close', (code) => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
if (timedOut) resolve({ status: 'timeout' });
|
|
198
|
+
else if (code === 0) resolve({ status: 'ok', stdout });
|
|
199
|
+
else resolve({ status: 'error', error: failureLine(stderr) || `${command} exited with code ${code}` });
|
|
200
|
+
});
|
|
201
|
+
child.stdin.end(stdin ?? '');
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Nothing leaves this module without passing the redaction layer. */
|
|
206
|
+
function safe(value) {
|
|
207
|
+
return redactSecrets(String(value ?? ''));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The line of stderr that explains the failure. Agentic CLIs open with banners and notices,
|
|
212
|
+
* so the first line is usually noise and the cause is further down.
|
|
213
|
+
*/
|
|
214
|
+
function failureLine(text) {
|
|
215
|
+
const lines = String(text ?? '')
|
|
216
|
+
.split('\n')
|
|
217
|
+
.map((line) => line.trim())
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
return lines.find((line) => /\b(error|failed|failure|unauthorized|forbidden|quota|unexpected|not found|invalid|denied)\b/i.test(line)) ?? lines.at(-1) ?? '';
|
|
220
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { configPath, readConfig } from '../config/language.mjs';
|
|
5
|
+
import { PROVIDERS, PROVIDER_IDS, findProvider, providerEndpoint, unknownProviderMessage } from './catalog.mjs';
|
|
6
|
+
|
|
7
|
+
export { PROVIDERS, PROVIDER_IDS, findProvider, unknownProviderMessage } from './catalog.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Status of a provider, decided without a single network call so `doctor` and `providers`
|
|
11
|
+
* stay offline:
|
|
12
|
+
*
|
|
13
|
+
* - `ready` the executable is on PATH, or the credential is set
|
|
14
|
+
* - `local` keyless local server: selectable, proven only by an actual request
|
|
15
|
+
* - `missing` no executable and no credential
|
|
16
|
+
* - `disabled` turned off in the user config
|
|
17
|
+
* - `blocked` not in the `providers:` allowlist of .reis-mobile/config.yaml
|
|
18
|
+
*/
|
|
19
|
+
const SELECTABLE = new Set(['ready', 'local']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Finds an executable by scanning PATH instead of shelling out to `which`: one less
|
|
23
|
+
* subprocess, and a platform the tests can inject.
|
|
24
|
+
*/
|
|
25
|
+
export function findExecutable(command, { env = process.env, platform = process.platform } = {}) {
|
|
26
|
+
const separator = platform === 'win32' ? ';' : ':';
|
|
27
|
+
const extensions = platform === 'win32' ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : [''];
|
|
28
|
+
for (const directory of String(env.PATH || '').split(separator).filter(Boolean)) {
|
|
29
|
+
for (const extension of extensions) {
|
|
30
|
+
const candidate = join(directory, `${command}${extension}`);
|
|
31
|
+
try {
|
|
32
|
+
accessSync(candidate, platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
33
|
+
return candidate;
|
|
34
|
+
} catch {
|
|
35
|
+
// Not here; keep scanning.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The allowlist a team commits in `.reis-mobile/config.yaml`, or null when absent. */
|
|
43
|
+
export function providerAllowlist(projectConfig) {
|
|
44
|
+
const value = projectConfig?.data?.providers;
|
|
45
|
+
if (value === undefined) return null;
|
|
46
|
+
const list = (Array.isArray(value) ? value : [value]).map((item) => String(item).trim()).filter(Boolean);
|
|
47
|
+
const unknown = list.filter((id) => !PROVIDER_IDS.includes(id));
|
|
48
|
+
if (unknown.length) {
|
|
49
|
+
return { ids: list.filter((id) => PROVIDER_IDS.includes(id)), warnings: unknown.map((id) => `${projectConfig.path}: ${unknownProviderMessage(id)}`) };
|
|
50
|
+
}
|
|
51
|
+
return { ids: list, warnings: [] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every provider with its status, resolved model and the evidence that decided both.
|
|
56
|
+
* `model` precedence: the `--model` flag, then the provider's model variable, then
|
|
57
|
+
* `providers.<id>.model` in the user config. CLI providers resolve no model: the spelling
|
|
58
|
+
* of their model flag is not verified here, so they keep the one they are configured with.
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveProviders({
|
|
61
|
+
env = process.env,
|
|
62
|
+
platform = process.platform,
|
|
63
|
+
model: flagModel,
|
|
64
|
+
allow = null,
|
|
65
|
+
config,
|
|
66
|
+
path = configPath({ env }),
|
|
67
|
+
} = {}) {
|
|
68
|
+
const settings = config ?? (await readConfig(path));
|
|
69
|
+
const providerSettings = settings.providers ?? {};
|
|
70
|
+
const warnings = [...(allow?.warnings ?? [])];
|
|
71
|
+
|
|
72
|
+
const providers = PROVIDERS.map((provider) => {
|
|
73
|
+
const setting = providerSettings[provider.id] ?? {};
|
|
74
|
+
const resolved = {
|
|
75
|
+
id: provider.id,
|
|
76
|
+
label: provider.label,
|
|
77
|
+
kind: provider.kind,
|
|
78
|
+
endpoint: providerEndpoint(provider, env),
|
|
79
|
+
docs: provider.docs,
|
|
80
|
+
needsModel: provider.needsModel === true,
|
|
81
|
+
model: null,
|
|
82
|
+
modelSource: null,
|
|
83
|
+
status: 'missing',
|
|
84
|
+
detail: '',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (provider.kind === 'cli') {
|
|
88
|
+
const executable = findExecutable(provider.command, { env, platform });
|
|
89
|
+
resolved.status = executable ? 'ready' : 'missing';
|
|
90
|
+
resolved.detail = executable ?? `${provider.command} not found in PATH`;
|
|
91
|
+
resolved.model = null;
|
|
92
|
+
resolved.modelSource = 'provider';
|
|
93
|
+
if (setting.model || (flagModel && executable)) {
|
|
94
|
+
warnings.push(`${provider.id}: reis-mobile does not set the model of a CLI provider; it uses its own configuration`);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
const key = provider.keys.find((candidate) => env[candidate]);
|
|
98
|
+
if (provider.local) {
|
|
99
|
+
resolved.status = 'local';
|
|
100
|
+
resolved.detail = `requires a server at ${resolved.endpoint}`;
|
|
101
|
+
} else {
|
|
102
|
+
resolved.status = key ? 'ready' : 'missing';
|
|
103
|
+
resolved.detail = key ? `${key} set` : `set ${provider.keys.join(' or ')}`;
|
|
104
|
+
}
|
|
105
|
+
const [value, source] = flagModel
|
|
106
|
+
? [flagModel, '--model']
|
|
107
|
+
: env[provider.modelEnv]
|
|
108
|
+
? [env[provider.modelEnv], provider.modelEnv]
|
|
109
|
+
: setting.model
|
|
110
|
+
? [setting.model, `providers.${provider.id}.model`]
|
|
111
|
+
: [null, null];
|
|
112
|
+
resolved.model = value;
|
|
113
|
+
resolved.modelSource = source;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (setting.enabled === false) {
|
|
117
|
+
resolved.status = 'disabled';
|
|
118
|
+
resolved.detail = `providers.${provider.id}.enabled is false`;
|
|
119
|
+
} else if (allow && !allow.ids.includes(provider.id)) {
|
|
120
|
+
resolved.status = 'blocked';
|
|
121
|
+
resolved.detail = 'not listed in the project providers allowlist';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
resolved.selectable = SELECTABLE.has(resolved.status);
|
|
125
|
+
// A provider that needs a model and has none is selectable but not runnable: asking for
|
|
126
|
+
// it explicitly reports `no-model`, while the default selection leaves it out.
|
|
127
|
+
resolved.runnable = resolved.selectable && (!resolved.needsModel || Boolean(resolved.model));
|
|
128
|
+
if (resolved.selectable && !resolved.runnable) resolved.detail = `${resolved.detail} · no model configured`;
|
|
129
|
+
return resolved;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return { providers, warnings, configPath: path };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The providers a run should talk to. Requested ids that are not usable are kept in the
|
|
137
|
+
* result with their status instead of being dropped: a missing provider is information the
|
|
138
|
+
* caller has to report, not an error to swallow.
|
|
139
|
+
*/
|
|
140
|
+
export function selectProviders(providers, requested, { requireModel = true } = {}) {
|
|
141
|
+
if (!requested || requested.length === 0) {
|
|
142
|
+
return providers.filter((provider) => (requireModel ? provider.runnable : provider.selectable));
|
|
143
|
+
}
|
|
144
|
+
const unknown = requested.filter((id) => !PROVIDER_IDS.includes(id));
|
|
145
|
+
if (unknown.length) throw new Error(unknownProviderMessage(unknown[0]));
|
|
146
|
+
return requested.map((id) => providers.find((provider) => provider.id === id));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `--providers codex,openai` */
|
|
150
|
+
export function parseProviderList(value) {
|
|
151
|
+
if (value === undefined || value === null) return null;
|
|
152
|
+
return String(value)
|
|
153
|
+
.split(',')
|
|
154
|
+
.map((item) => item.trim())
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
}
|
package/core/router/intents.mjs
CHANGED
|
@@ -28,6 +28,21 @@ export const INTENTS = {
|
|
|
28
28
|
'feature first', 'estrutura de pastas', 'state management', 'gerenciamento de estado',
|
|
29
29
|
'injecao de dependencia', 'dependency injection', 'design pattern', 'camada',
|
|
30
30
|
],
|
|
31
|
+
offline: [
|
|
32
|
+
'offline', 'off-line', 'sem conexao', 'sem internet', 'sem rede', 'modo aviao',
|
|
33
|
+
'airplane mode', 'sincroniza', 'sync', 'dessincroniz', 'out of sync', 'conflito',
|
|
34
|
+
'conflict', 'idempot', 'fila de operac', 'operation queue', 'outbox', 'reconect',
|
|
35
|
+
'reconnect', 'local-first', 'local first', 'cache local', 'last write wins',
|
|
36
|
+
'ultima escrita vence', 'replica',
|
|
37
|
+
],
|
|
38
|
+
observability: [
|
|
39
|
+
'observab', 'telemetri', 'telemetry', 'opentelemetry', 'crashlytics', 'sentry',
|
|
40
|
+
'datadog', 'new relic', 'firebase analytics', 'analytics', 'monitorament', 'monitoring',
|
|
41
|
+
'structured log', 'log estruturad', 'logging', 'alerta', 'alert', 'sampling',
|
|
42
|
+
'amostragem', 'distributed trac', 'trace distribu', 'correlation id', 'correlation-id',
|
|
43
|
+
'crash-free', 'crash free', 'error reporting', 'relatorio de erro', 'breadcrumb',
|
|
44
|
+
'slo', 'sli',
|
|
45
|
+
],
|
|
31
46
|
release: [
|
|
32
47
|
'release', 'publica', 'publish', 'loja', 'app store', 'play store', 'testflight',
|
|
33
48
|
'lancamento', 'submission', 'store review', 'rejeita', 'reject',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reis-mobile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "AI agents and skills for Flutter, Android, iOS, React Native and Kotlin Multiplatform — a Claude Code and Codex plugin for reviews, debugging, tests, architecture, security and Firebase.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,9 +18,12 @@ One accessibility process for every mobile stack. This file holds what is true e
|
|
|
18
18
|
| Native iOS: SwiftUI or UIKit | [references/ios.md](references/ios.md) |
|
|
19
19
|
| React Native or Expo | [references/react-native.md](references/react-native.md) |
|
|
20
20
|
| Kotlin Multiplatform with shared Compose UI or native host UI | [references/kotlin-multiplatform.md](references/kotlin-multiplatform.md) |
|
|
21
|
+
| Localization, plurals, formatting and right-to-left layout, on any stack | [references/localization.md](references/localization.md) |
|
|
21
22
|
|
|
22
23
|
In Flutter and React Native apps, the native reference applies only to screens or components written natively (platform views, native modules with UI).
|
|
23
24
|
|
|
25
|
+
Localization is reviewed with accessibility because it breaks the same things — a layout that assumed one text length, a fixed-height label, text baked into an image. When the app ships in more than one language, or in a right-to-left one, read [references/localization.md](references/localization.md) alongside this file.
|
|
26
|
+
|
|
24
27
|
## 2. What a screen reader user needs
|
|
25
28
|
|
|
26
29
|
Every interactive element exposes four things to the accessibility tree. Check them in this order:
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Localization and internationalization
|
|
2
|
+
|
|
3
|
+
Applies to every stack. Localization sits next to accessibility because it breaks the same things: a layout that assumed one text length, a string that assumed one grammar, and a date that assumed one calendar. An app that survives the largest accessibility font size usually survives German; one that does not, does not.
|
|
4
|
+
|
|
5
|
+
## The rules that hold on every platform
|
|
6
|
+
|
|
7
|
+
**No user-visible string in code.** Every string lives in the platform's resource system, keyed, with the source language as the base. A hard-coded string is not translatable and will not be found by review; grep for string literals inside UI code as the first pass of an audit.
|
|
8
|
+
|
|
9
|
+
**Never build a sentence by concatenation.** `"You have " + n + " messages"` cannot be translated: word order, pluralization and grammatical gender all differ. Use a parameterized string with named placeholders, so the translator can reorder them:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
"order_shipped_on": "Your order {orderId} ships on {date}"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Plurals are a category, not a number.** English has two forms; Polish has four; Arabic has six; Japanese has one. `if (n == 1)` is wrong in most languages. Every platform implements the CLDR plural categories (`zero`, `one`, `two`, `few`, `many`, `other`) — use them:
|
|
16
|
+
|
|
17
|
+
| Stack | Mechanism |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Android | `<plurals>` resources and `getQuantityString` |
|
|
20
|
+
| iOS | A stringsdict / the `String Catalog`'s plural variations, with `NSLocalizedString` |
|
|
21
|
+
| Flutter | ICU message syntax in ARB files (`{count, plural, =0{...} one{...} other{...}}`) with `flutter_localizations` and `gen_l10n` |
|
|
22
|
+
| React Native | `i18next` with the ICU or plural suffix conventions, `react-intl`, or `Intl.PluralRules` directly |
|
|
23
|
+
| Kotlin Multiplatform | Compose Multiplatform resources, or host resources through `expect`/`actual` — check the plural support of the version in use |
|
|
24
|
+
|
|
25
|
+
**Never format data by hand.** Dates, times, numbers, currencies, percentages, lists and relative times all have locale rules, and `"$" + amount` is wrong in most of the world. Use the platform formatter (`NumberFormat`/`DateTimeFormatter`, `Intl.*`, `NumberFormat`/`DateFormat` in Dart's `intl`, `FormatStyle` in Swift). Currency in particular: the symbol, its position, the decimal separator and the number of decimal places are all per-locale, and the currency itself is a property of the *price*, not of the user.
|
|
26
|
+
|
|
27
|
+
**Device locale, app language, and region are three things.** A user can read Portuguese, live in Japan and want metric units. Recent platforms support a per-app language setting (Android's per-app language preferences, iOS's app language in Settings) — support it rather than reading only the device's first locale, and never infer language from the country or from a SIM.
|
|
28
|
+
|
|
29
|
+
## Right-to-left
|
|
30
|
+
|
|
31
|
+
Arabic, Hebrew, Persian and Urdu mirror the interface. Getting this right is mostly a matter of never expressing a direction as "left" or "right":
|
|
32
|
+
|
|
33
|
+
- Use **start/end** everywhere: `paddingStart`, `layoutDirection`-aware `EdgeInsets.directional`, `marginStart`, SwiftUI's automatic leading/trailing. A `paddingLeft` is a bug in RTL.
|
|
34
|
+
- **Icons that imply direction mirror; icons that depict objects do not.** A back arrow and a progress chevron mirror; a play button, a clock and a logo do not. Both platforms have a flag for automatic mirroring (`autoMirrored` in Android vector drawables and Material icons, `flipsForRightToLeftLayoutDirection` on iOS images, `matchTextDirection` in Flutter); set it deliberately per icon.
|
|
35
|
+
- **Numbers and embedded Latin text inside RTL** follow the bidirectional algorithm and can render in a surprising order. Test with real strings containing a phone number, a price and a URL.
|
|
36
|
+
- Test it without waiting for a translation: Android's "Force RTL layout direction" developer option, Xcode's right-to-left pseudolanguage, and Flutter's `Directionality` widget in a test all show the layout before any Arabic exists.
|
|
37
|
+
|
|
38
|
+
## Layout that survives translation
|
|
39
|
+
|
|
40
|
+
- **Assume text grows.** German, Finnish and Russian commonly run 30–50% longer than English, and short strings grow proportionally more. Buttons and tabs with fixed widths, and single-line labels with `maxLines: 1` and no ellipsis, are where this shows.
|
|
41
|
+
- **No fixed heights on text containers**, for the same reason as text scaling in [the accessibility checklist](../SKILL.md).
|
|
42
|
+
- **Do not put text into images.** It cannot be translated, it does not scale with the font size setting, and screen readers cannot read it.
|
|
43
|
+
- **Pseudolocalization** finds these before translation exists: both platforms can render the app in an accented, expanded pseudo-language that makes truncation and hard-coded strings obvious at a glance. Run it in the normal review pass.
|
|
44
|
+
|
|
45
|
+
## Giving translators what they need
|
|
46
|
+
|
|
47
|
+
A translator sees a key and a string, not the screen. Ambiguous strings become wrong translations:
|
|
48
|
+
|
|
49
|
+
- **Comments on every non-obvious string.** "Open" as a verb on a button and "Open" as a status adjective are different words in most languages, and the resource comment is the only way the translator knows which.
|
|
50
|
+
- **Character limits** where the UI constrains the text, declared with the string.
|
|
51
|
+
- **Stable keys** that describe the role (`checkout_confirm_button`), not the English text — renaming the English copy should not orphan the translation.
|
|
52
|
+
- **Keep the placeholders documented**: what `{count}` counts, what `{name}` names.
|
|
53
|
+
|
|
54
|
+
## Reviewing
|
|
55
|
+
|
|
56
|
+
- [ ] No user-visible string literals in UI code.
|
|
57
|
+
- [ ] No sentence assembled by concatenation or string interpolation of fragments.
|
|
58
|
+
- [ ] Plurals use the platform's plural resources, not `if (n == 1)`.
|
|
59
|
+
- [ ] Dates, numbers and currencies use a locale-aware formatter; no manual symbol or separator.
|
|
60
|
+
- [ ] Layout uses start/end, not left/right; directional icons are marked as mirrorable.
|
|
61
|
+
- [ ] No fixed widths or heights on text containers; no text baked into images.
|
|
62
|
+
- [ ] The per-app language setting is supported, and the app does not infer language from the region.
|
|
63
|
+
- [ ] Strings carry comments and, where relevant, length limits.
|
|
64
|
+
- [ ] The app has been seen in pseudolocale and in forced RTL, at the largest font size.
|
|
65
|
+
- [ ] Anything untranslated falls back to the base language rather than showing a key.
|
|
66
|
+
|
|
67
|
+
The last three are the ones that catch real defects. Everything else can pass review and still produce a broken screen in Arabic at 200% font size, which is why the check is visual.
|
|
68
|
+
|
|
69
|
+
## Official documentation
|
|
70
|
+
|
|
71
|
+
- Android localization: https://developer.android.com/guide/topics/resources/localization
|
|
72
|
+
- Android per-app language preferences: https://developer.android.com/guide/topics/resources/app-languages
|
|
73
|
+
- Android bidirectional text support: https://developer.android.com/training/basics/supporting-devices/languages
|
|
74
|
+
- Apple localization: https://developer.apple.com/documentation/xcode/localization
|
|
75
|
+
- Flutter internationalization: https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization
|
|
76
|
+
- React Native right-to-left and localization: https://reactnative.dev/blog/2016/08/19/right-to-left-support-for-react-native-apps
|
|
77
|
+
- Unicode CLDR plural rules: https://cldr.unicode.org/index/cldr-spec/plural-rules
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-observability
|
|
3
|
+
description: Designs and reviews production observability for mobile apps on every stack — Flutter, native Android, native iOS, React Native and Kotlin Multiplatform. Covers crash and error reporting with usable context, breadcrumbs, structured logs, traces and custom metrics, release and cohort segmentation, correlation with backend requests, alerting thresholds, sampling and cost, and what must never be collected. Use when production problems cannot be explained from the available data, when setting up crash reporting or telemetry, or when defining release health alerts.
|
|
4
|
+
intents: [observability]
|
|
5
|
+
stacks: ["*"]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Mobile Observability
|
|
9
|
+
|
|
10
|
+
What you can learn about the app after it has shipped, on every stack. This file holds the model, the segmentation rules and the privacy limits; each platform's SDKs, symbol upload and field-metric sources live in its reference.
|
|
11
|
+
|
|
12
|
+
## 1. Pick the reference
|
|
13
|
+
|
|
14
|
+
| Project | Read |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Flutter | [references/flutter.md](references/flutter.md) |
|
|
17
|
+
| Native Android | [references/android.md](references/android.md) |
|
|
18
|
+
| Native iOS | [references/ios.md](references/ios.md) |
|
|
19
|
+
| React Native or Expo | [references/react-native.md](references/react-native.md) |
|
|
20
|
+
| Kotlin Multiplatform | [references/kotlin-multiplatform.md](references/kotlin-multiplatform.md) |
|
|
21
|
+
|
|
22
|
+
Firebase Crashlytics, Analytics and Performance Monitoring setup per stack is in [mobile-firebase](../mobile-firebase/SKILL.md). Release gating on these signals is in [mobile-release](../mobile-release/SKILL.md); the lab counterpart of the performance metrics is in [mobile-performance](../mobile-performance/SKILL.md).
|
|
23
|
+
|
|
24
|
+
## 2. Mobile is not a server
|
|
25
|
+
|
|
26
|
+
Everything below follows from four differences, and ignoring them is what produces a telemetry setup that costs money and answers nothing:
|
|
27
|
+
|
|
28
|
+
1. **You cannot log in to the device.** There is no `kubectl logs`. If the data was not collected and sent before the user gave up, it does not exist.
|
|
29
|
+
2. **The old version never goes away.** A server rolls back in minutes; an app version lives on devices for months. Every signal must be sliced by app version, or a fixed bug and a live bug look identical in the aggregate.
|
|
30
|
+
3. **The device is the constraint.** Telemetry costs the user's battery, data plan and storage. A payload sent per event is a wakeup per event — see [background.md](../mobile-performance/references/background.md).
|
|
31
|
+
4. **Reports arrive late and out of order.** A crash is reported on the *next* launch; a device offline for a week uploads a week later. Never read the last hour of a metric as "what is happening now" without accounting for the reporting delay.
|
|
32
|
+
|
|
33
|
+
## 3. The signals, and what each one answers
|
|
34
|
+
|
|
35
|
+
| Signal | Answers | Usual failure |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| **Crashes** | The process died. Where, on which OS/device/version | Obfuscated stack traces — symbols not uploaded |
|
|
38
|
+
| **Non-fatal errors** | A caught failure the user saw as a broken screen | Everything caught and logged, so the important one is invisible |
|
|
39
|
+
| **ANRs / hangs / terminations** | The app froze or was killed, without a crash report | Only watching crashes and missing the majority of bad sessions |
|
|
40
|
+
| **Breadcrumbs** | What the user did in the seconds before the failure | Free text with no structure, useless for grouping |
|
|
41
|
+
| **Structured logs** | The detail behind one session | Sent for every session, at full volume, at full cost |
|
|
42
|
+
| **Traces / spans** | Where the time went in one flow, across app and backend | Instrumented only in the app, so the backend half is invisible |
|
|
43
|
+
| **Metrics** | The aggregate: latency percentiles, success rates, adoption | Averages instead of percentiles |
|
|
44
|
+
| **Product analytics** | Whether the user completed the journey at all | Treated as observability; it shows the drop, never the cause |
|
|
45
|
+
|
|
46
|
+
A production incident needs at least three of these to be explainable: **what broke** (crash/error), **what led to it** (breadcrumbs/logs), and **how many users** (metrics segmented by version).
|
|
47
|
+
|
|
48
|
+
## 4. Segmentation: the dimensions that must be on every signal
|
|
49
|
+
|
|
50
|
+
Attach these to every crash, error, span and metric, or the data cannot be sliced when it matters:
|
|
51
|
+
|
|
52
|
+
- **App version and build number** — non-negotiable; every comparison is version-over-version.
|
|
53
|
+
- **OS version and device model** — most "random" crashes are one OS version or one manufacturer.
|
|
54
|
+
- **Release channel / rollout stage** — internal, beta, staged rollout percentage, full.
|
|
55
|
+
- **A stable install id**, not a user id, for counting affected users. It must be resettable and must not follow the person across reinstalls or apps (see §8).
|
|
56
|
+
- **Locale and region** where formatting, right-to-left layout or regulation differ.
|
|
57
|
+
- **Connectivity class** at the time (offline, cellular, Wi-Fi) for anything network-related.
|
|
58
|
+
- **Feature flag / experiment state** for flags that change the code path. A crash present only in one variant is otherwise unattributable.
|
|
59
|
+
|
|
60
|
+
Keep the cardinality bounded. A dimension whose value is a user id, a timestamp or a free-text message turns a metric into millions of series and is the standard way to make an observability bill unpayable.
|
|
61
|
+
|
|
62
|
+
## 5. Error context: make one report enough
|
|
63
|
+
|
|
64
|
+
A stack trace alone rarely explains a mobile failure. A report is useful when it carries:
|
|
65
|
+
|
|
66
|
+
- **The stack trace, symbolicated.** Verify symbol upload in CI for every build variant — an unsymbolicated release build is the most common and most expensive gap. The reference names the mechanism per stack.
|
|
67
|
+
- **Custom keys** for the state that decides the code path: the screen, the entity id being acted on, the auth state, whether the outbox was non-empty, the flag values.
|
|
68
|
+
- **Breadcrumbs as structured events**, not sentences: `screen_view{name}`, `network{method, host, status, ms}`, `tap{target}`, `state_change{from, to}`. Cap them (the last 50–100) and drop the payloads.
|
|
69
|
+
- **The grouping key.** Reports group by stack signature; a generic wrapper (`throw AppException(e)`) collapses a hundred distinct causes into one issue and hides all of them. Preserve the cause chain, and put the variable detail in keys, not in the exception message.
|
|
70
|
+
- **A correlation id** shared with the backend (§6).
|
|
71
|
+
- **Whether the user saw it.** A caught-and-recovered error and a blank screen are different severities and should be distinguishable without reading code.
|
|
72
|
+
|
|
73
|
+
## 6. Correlation with the backend
|
|
74
|
+
|
|
75
|
+
Mobile latency is mostly backend latency, and the app's "the request was slow" is unactionable on its own.
|
|
76
|
+
|
|
77
|
+
- Generate a **request id** per outbound call on the device, send it in a header, log it on both sides, and put it on the client-side span and on any error report from that call. One id makes "slow for this user at that minute" a single backend query instead of a guess.
|
|
78
|
+
- Use **W3C Trace Context** (`traceparent`) when the backend is already traced, so the mobile span is the parent of the server spans rather than a disconnected record. OpenTelemetry clients exist for mobile; the reference names the current state per stack.
|
|
79
|
+
- Attach a **session id** that spans the app session, so several failing calls can be recognized as one bad session.
|
|
80
|
+
- **Propagate nothing sensitive** in these headers, and never put a token in a trace attribute.
|
|
81
|
+
|
|
82
|
+
Without correlation, the mobile team and the backend team argue from two datasets. With it, the argument is a join.
|
|
83
|
+
|
|
84
|
+
## 7. Alerting: on the few things that mean "stop the rollout"
|
|
85
|
+
|
|
86
|
+
Most mobile alerts are noise because they fire on a raw count that tracks daily usage. Alert on **rates**, segmented by version, with the reporting delay in mind:
|
|
87
|
+
|
|
88
|
+
| Alert | Reasonable shape |
|
|
89
|
+
|---|---|
|
|
90
|
+
| Crash-free sessions drop | Below your baseline (commonly 99.5%+ for a mature app) **for the new version**, compared against the previous one |
|
|
91
|
+
| ANR / hang rate | Above the platform's own bad-behaviour threshold; both stores act on this |
|
|
92
|
+
| New issue in a new release | Any issue whose first occurrence is this version and which affects more than N users — this is the one that stops a staged rollout |
|
|
93
|
+
| Critical journey success rate | Login, checkout, sync drain: success percentage per version, not per absolute count |
|
|
94
|
+
| Startup and key-screen latency | P90, per version, against the previous release's value |
|
|
95
|
+
|
|
96
|
+
Rules that keep alerts trustworthy: compare a version against the previous version and not against last week; require a minimum session count before a percentage can fire; suppress alerts during the first minutes of a rollout when the denominator is tiny; and route each alert to someone who can act — a page for release health, a dashboard for everything else. An alert nobody acts on should be deleted, not muted.
|
|
97
|
+
|
|
98
|
+
## 8. Sampling and cost
|
|
99
|
+
|
|
100
|
+
Full-fidelity telemetry from every session is neither affordable nor kind to the device.
|
|
101
|
+
|
|
102
|
+
- **Never sample crashes, ANRs or fatal errors.** They are rare and each one matters.
|
|
103
|
+
- **Sample traces and verbose logs** — a small percentage of sessions at full detail answers most questions. Make the decision **per session, not per event**, so a sampled session is complete and readable end to end.
|
|
104
|
+
- **Always keep the sessions that failed.** Sampling that discards the broken session is worse than no sampling: raise the rate for sessions with an error, or buffer locally and upload only on failure.
|
|
105
|
+
- **Batch uploads**, with a size and a time bound, and flush on background/terminate. One flush per event is a radio wakeup per event.
|
|
106
|
+
- **Bound the local buffer** and drop the oldest when it is full; an unbounded telemetry queue fills the device and turns a monitoring feature into a support ticket.
|
|
107
|
+
- **Respect the network.** Do not upload telemetry on metered or constrained connections unless it is a crash report.
|
|
108
|
+
- **Record the sampling rate with the data.** A percentile computed from a sampled set without knowing the rate is wrong, and someone will read it as absolute.
|
|
109
|
+
|
|
110
|
+
## 9. What must never be collected
|
|
111
|
+
|
|
112
|
+
Observability is where privacy violations happen by accident, because logging "the request" logs whatever was in it.
|
|
113
|
+
|
|
114
|
+
- **Never log:** passwords, tokens, refresh tokens, API keys, full payment data, government ids, health data, precise location, message or document contents, or full request/response bodies from authenticated endpoints.
|
|
115
|
+
- **Redact at the source**, in the SDK's callback before send (every reporter has one), not in the backend pipeline. Data that left the device is already disclosed.
|
|
116
|
+
- **URLs carry secrets** in query strings — strip query parameters, or allow-list them. A URL is an attribute, not a free-text field.
|
|
117
|
+
- **Crash reports include more than you sent**: register values, thread names, and sometimes fragments of memory in native crashes. Treat the whole report as sensitive and restrict who can read it.
|
|
118
|
+
- **Consent gates collection.** On both stores, telemetry and analytics must match the declared data-safety/privacy-nutrition disclosures and the app's consent state. A user who declined analytics must not have analytics sent, and the SDK must be initialized accordingly — not initialized and then asked to stay quiet.
|
|
119
|
+
- **Prefer aggregation and short retention** for anything user-linked, and be able to delete a person's data on request, which requires the install id to be mappable and resettable.
|
|
120
|
+
|
|
121
|
+
This overlaps with [mobile-security](../mobile-security/SKILL.md); when in doubt, the answer is to send less.
|
|
122
|
+
|
|
123
|
+
## 10. Reviewing an existing setup
|
|
124
|
+
|
|
125
|
+
- [ ] Symbol/mapping upload happens in CI for every shipped variant, and a release build's crash is verifiably readable.
|
|
126
|
+
- [ ] App version, OS, device, channel and flag state are on every signal.
|
|
127
|
+
- [ ] ANRs, hangs and background terminations are collected, not just crashes.
|
|
128
|
+
- [ ] Caught errors are distinguishable from user-visible failures, and grouping is not collapsed by a generic wrapper.
|
|
129
|
+
- [ ] Breadcrumbs are structured and capped; logs are sampled; crashes are not.
|
|
130
|
+
- [ ] A client-generated correlation id reaches the backend and appears in error reports.
|
|
131
|
+
- [ ] Alerts are rate-based, per version, with a minimum-volume guard, and each one has an owner.
|
|
132
|
+
- [ ] Telemetry uploads are batched, bounded, flushed on background, and off on metered connections.
|
|
133
|
+
- [ ] A redaction callback exists and is tested; no secrets, bodies or precise location are sent.
|
|
134
|
+
- [ ] Collection respects consent and matches the store privacy declarations.
|
|
135
|
+
- [ ] Someone can answer "how many users on 4.2.0 hit this, and what were they doing" without adding code.
|
|
136
|
+
|
|
137
|
+
The last line is the real test. If answering an ordinary production question requires shipping a new build and waiting two weeks for adoption, the setup has failed regardless of how many SDKs are installed.
|