PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / schema / feedback_payload_schema.php

feedback_payload_schema.php in 404 Solution trunk, at includes/schema/feedback_payload_schema.php

279 lines 15.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Wire-schema for ABJ_404_Solution_FeedbackTransport payloads.
9 *
10 * This file is the contract between:
11 * - the PHP producer (FeedbackTransport::buildPayload), tested by
12 * FeedbackPayloadSchemaContractTest;
13 * - the JS producer of the uninstall AJAX form (uninstall-modal.js),
14 * which ships the user-facing extras the PHP builder folds in;
15 * - the reports server endpoint, which accepts these fields and stores
16 * them in typed columns or the environment JSON passthrough.
17 *
18 * Each report type (`error`, `heartbeat`, `uninstall`, `support_request`)
19 * shares a base set of fields and adds a small per-type extras section.
20 * The schema is
21 * declared once and merged per-type so a new field added in one place
22 * (say a new server column) requires updates only here, not in three
23 * test files.
24 *
25 * Reference fixes that this schema would have caught:
26 *
27 * - 4080ffb5 B4: resource_limits values shipped as ini shorthand
28 * strings ("256M", "30") instead of integers (bytes/seconds). The
29 * `php_memory`, `wp_memory`, `php_post_max_size`, etc. specifiers
30 * below pin those to `int`.
31 * - 4080ffb5 B4 (field-name): the resource_limits map used names that
32 * did not match the server columns (`max_execution_time` vs
33 * `php_max_execution_seconds`). `unexpected_field` detection +
34 * declared key names catch this.
35 * - 4080ffb5 B5: `active_theme` shipped as `{name, version}` object but
36 * server schema declares `string`. `object_cache` shipped as bool,
37 * server expects "external"/"default" string. `extensions` shipped
38 * as `array<string>`, server expects `object` keyed by ext name.
39 * The specifiers below force the string/object shapes.
40 * - 35380dcc: nested `content_counts` / `redirect_counts` /
41 * `captured_counts` arrays were flattened to top-level fields.
42 * Listing every flat field as required + `unexpected_field` rejects
43 * the nested re-introduction.
44 *
45 * Automatic vs manual submission is DERIVED from this file, never listed
46 * separately. A field a person typed into a form carries
47 * `'human_typed' => 'message'` (prose they wrote) or
48 * `'human_typed' => 'contact'` (a reply address they gave); a report type
49 * with at least one such field is one a human filled in and clicked to
50 * send, and is therefore worth telling a human about. `error` and
51 * `heartbeat` have none and stay silent. Deriving it here rather than
52 * hardcoding a type list elsewhere is what makes a future fifth type with
53 * a textarea notify without anyone remembering to update the triage side:
54 * declaring the field is the only step. Read by
55 * scripts/feedback-submission-modes.php (consumed in turn by
56 * scripts/error-report-sweep.sh) and locked by
57 * FeedbackSubmissionModeClassificationTest.
58 *
59 * Returns array<string, array<string, array<string, mixed>>> keyed by
60 * report type. Each value is a flat FieldSpec map ready to feed to
61 * ABJ_404_Solution_PayloadSchema::validate().
62 */
63
64 return (function (): array {
65
66 // Base fields shared by every report type. The producer always
67 // returns these regardless of `$type`; the per-type extras are
68 // unioned in below.
69 $base = [
70 // Plugin and request metadata
71 'payload_schema_version' => [
72 'type' => 'int',
73 'enum' => [2],
74 'description' => 'Additive feedback wire-contract version. Version 2 adds the optional plugin_settings diagnostic snapshot.',
75 ],
76 'plugin_version' => ['type' => 'string', 'description' => 'ABJ404_VERSION at send time. Empty string is allowed only in early-boot test contexts.'],
77 'report_type' => ['type' => 'string', 'enum' => ['error', 'heartbeat', 'uninstall', 'support_request']],
78 'is_uninstall' => ['type' => 'bool', 'description' => 'Back-compat alias for report_type=uninstall. True iff report_type=uninstall.'],
79 'site_url' => ['type' => 'string', 'description' => 'home_url(). Server GROUP BY key.'],
80 'locale' => ['type' => 'string'],
81
82 // Database identity
83 'db_type' => ['type' => 'string', 'enum' => ['mysql', 'mariadb', 'sqlite', 'other']],
84 'db_version' => ['type' => 'string'],
85 'table_prefix' => ['type' => 'string'],
86
87 // WordPress identity
88 'wp_version' => ['type' => 'string'],
89 'is_multisite' => ['type' => 'bool'],
90 'wp_debug' => ['type' => 'bool'],
91
92 // PHP runtime identity
93 'php_version' => ['type' => 'string'],
94 'server_software' => [
95 'type' => 'string',
96 'description' => 'Server software banner, with the Apache mod_status "Server at <host> Port <n>" footer stripped before transmit and capped to 100 chars. See FeedbackTransport::sanitizeServerSoftware().',
97 ],
98
99 // Resource limits. Every value is bytes (for size fields) or
100 // seconds (for time fields). Strings like "256M" are a schema
101 // violation: that was bug 4080ffb5 B4.
102 'resource_limits' => [
103 'type' => 'object',
104 'key_type' => 'string',
105 'value_type' => 'int',
106 'description' => 'Bytes for size fields, seconds for time fields. Never ini shorthand strings.',
107 ],
108 'wp_memory_limit_bytes' => ['type' => 'int|null', 'description' => 'Convenience top-level alias for resource_limits.wp_memory.'],
109
110 // Extensions and active plugins
111 'extensions' => [
112 'type' => 'object',
113 'key_type' => 'string',
114 'value_type' => 'bool',
115 'description' => '{curl:true, mbstring:true, ...} map. Bug 4080ffb5 B5 shipped this as array<string>; the server rejected it.',
116 ],
117 'active_plugins' => ['type' => 'array', 'item_type' => 'string'],
118 'active_theme' => ['type' => 'string', 'description' => '"Name Version" string. Bug 4080ffb5 B5 shipped {name,version} object.'],
119
120 // Cache identity
121 'object_cache' => [
122 'type' => 'string',
123 'enum' => ['external', 'default'],
124 'description' => 'Bug 4080ffb5 B5 shipped this as bool. Server schema is string enum.',
125 ],
126
127 // Content-count diagnostics. Nullable because the underlying
128 // wp_count_posts / wp_count_terms can fail; null distinguishes
129 // "lookup failed" from "really zero".
130 'published_posts_count' => ['type' => 'int|null'],
131 'published_pages_count' => ['type' => 'int|null'],
132 'categories_count' => ['type' => 'int|null'],
133 'tags_count' => ['type' => 'int|null'],
134
135 // Redirect status counts. Flat layout was required by server
136 // schema (commit 35380dcc); the nested layout below the line is
137 // explicitly forbidden by `unexpected_field`.
138 'redirects_active_total' => ['type' => 'int|null'],
139 'redirects_manual_count' => ['type' => 'int|null'],
140 'redirects_automatic_count' => ['type' => 'int|null'],
141 'redirects_regex_count' => ['type' => 'int|null'],
142 'redirects_trashed_count' => ['type' => 'int|null'],
143 'redirects_status_counts_state' => [
144 'type' => 'string',
145 'enum' => ['fresh', 'stale', 'uncomputed', 'unavailable', 'redacted'],
146 'description' => 'Why the five redirects_* tallies above hold what they hold. The tallies are served from a cache that a background job fills, so NULL/0 alone cannot distinguish "this site has no redirects" from "no count has ever been computed here" (uncomputed) or "the read service could not be reached" (unavailable). `stale` means the counts came from the last-known cache while a refresh is pending.',
147 ],
148 'redirect_hit_count_histogram' => [
149 'type' => 'object|null',
150 'key_type' => 'string',
151 'value_type' => 'int',
152 'description' => 'Aggregate active-redirect hit-count buckets only: zero_hits, one_to_ten_hits, eleven_to_hundred_hits, over_hundred_hits. Never per-redirect URLs.',
153 ],
154
155 // Captured-404 status counts.
156 'captured_404s_active_total' => ['type' => 'int|null'],
157 'captured_404s_new_count' => ['type' => 'int|null'],
158 'captured_404s_ignored_count' => ['type' => 'int|null'],
159 'captured_404s_later_count' => ['type' => 'int|null'],
160 'captured_404s_trashed_count' => ['type' => 'int|null'],
161 'captured_404s_status_counts_state' => [
162 'type' => 'string',
163 'enum' => ['fresh', 'stale', 'uncomputed', 'unavailable', 'redacted'],
164 'description' => 'Same discriminator as redirects_status_counts_state, for the five captured_404s_* tallies above.',
165 ],
166
167 // Log + debug file health.
168 'log_entries_count' => ['type' => 'int|null'],
169 'log_table_size_bytes' => ['type' => 'int|null'],
170 'error_count_in_log' => ['type' => 'int|null'],
171 'debug_file_size_bytes' => ['type' => 'int|null'],
172
173 // Optional dev-environment marker. Only present when the
174 // producer detected a dev host; absence on real prod sites is
175 // expected and not a schema violation.
176 'environment_type' => [
177 'type' => 'string',
178 'enum' => ['development'],
179 'required' => false,
180 ],
181
182 // Bruno/Troy diagnostic passthrough. Sites where the redirects
183 // tab times out or rebuild stalls cannot be triaged from typed
184 // columns alone: the binding constraints are MySQL globals
185 // (innodb_buffer_pool_size, tmp_table_size), disk headroom,
186 // and PHP SAPI specifics that the server doesn't pre-declare.
187 // Stored on the server in the JSON passthrough column (the
188 // existing `extras_json` field on the reports row), keyed by
189 // a stable namespace so future probes can land here without
190 // schema changes on either side. Producer-side detail:
191 // FeedbackTransport::environmentExtras().
192 'environment_extras' => [
193 'type' => 'object',
194 'key_type' => 'string',
195 // value_type intentionally omitted: this is the JSON
196 // passthrough, mixed scalar/object/array values allowed.
197 'description' => 'Best-effort site diagnostics: MySQL globals + status counters + session probe, disk free/total, PHP SAPI / opcache (on/off + detail settings), plugin table sizes (with data_free fragmentation), view-build freshness state, active connection count, per-index cardinality, hosting + panel class, object-cache backend, DB charset/collate + per-column collation, WP+PHP timezone, plugin install/upgrade lifecycle, top recurring error signatures, opcache revalidate/validate/cli detail, open_basedir restriction, multisite role + network-activation, .htaccess writability, /tmp filesystem free bytes. Anything new diagnosed for a recurring-user failure goes here first, then optionally graduates to a typed column.',
198 ],
199
200 'plugin_settings' => [
201 'type' => 'object',
202 'key_type' => 'string',
203 'required' => false,
204 'description' => 'Deny-by-default snapshot of allowlisted behavioral settings. Credentials, emails, free text, and paths are never included.',
205 ],
206 ];
207
208 $errorExtras = [
209 'error_signature' => ['type' => 'string'],
210 'previously_sent_line' => ['type' => 'int'],
211 'debug_log' => [
212 'type' => 'string',
213 'description' => 'Sanitized recent debug-log tail for opted-in error diagnostics. It remains a tail under evidence schema v1; when the named error is older, debug_log_evidence carries a separate non-overlapping anchor and both fields share 262144 bytes.',
214 ],
215 'debug_log_evidence' => [
216 'type' => 'object',
217 'key_type' => 'string',
218 'required' => false,
219 'description' => 'Additive versioned metadata and anchored excerpt for the error named by this report. schema_version=1 preserves debug_log as a tail and allocates one shared 262144-byte budget across the tail and non-overlapping error_excerpt.',
220 ],
221 ];
222
223 $heartbeatExtras = [
224 'error_signature' => ['type' => 'string'],
225 'previously_sent_line' => ['type' => 'int'],
226 ];
227
228 // `human_typed` marks the fields a PERSON filled in by hand. Only the
229 // three free-text inputs and the reply address qualify: uninstall_reason
230 // is a radio choice, selected_issues is a checkbox join, and
231 // include_diagnostics is a checkbox, none of which is typed prose.
232 // See the note above $supportRequestExtras for what reads this marker.
233 $uninstallExtras = [
234 'uninstall_reason' => ['type' => 'string'],
235 'selected_issues' => ['type' => 'string', 'description' => 'Comma-joined checkbox values from the modal (sanitized server-side).'],
236 'followup_details' => ['type' => 'string', 'human_typed' => 'message'],
237 'better_plugin_name' => ['type' => 'string', 'required' => false, 'human_typed' => 'message'],
238 'other_reason_text' => ['type' => 'string', 'required' => false, 'human_typed' => 'message'],
239 'contact_email' => ['type' => 'string', 'human_typed' => 'contact'],
240 'include_diagnostics' => ['type' => 'bool'],
241 'debug_log' => ['type' => 'string'],
242 ];
243
244 // type='support_request' carries 4 user-facing extras on top of the
245 // standard diagnostic base. Sent by Ajax_SupportRequest, which is the
246 // only producer of this type today; the JS form is bound to a fixed
247 // set of trigger surfaces (the "Send support request" button on the
248 // redirects page, the captured-404s page, the plugins-row action, the
249 // settings debug screen, and the corrupt-install fallback screen).
250 // The triggered_from enum is pinned to the producer's allow-list so
251 // a drift in either direction (PHP adds a surface the schema doesn't
252 // list, or JS posts a value PHP did not accept) fails the wire-schema
253 // validator before the server endpoint sees it. Keep this list in
254 // sync with ABJ_404_Solution_Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES.
255 $supportRequestExtras = [
256 'user_message' => ['type' => 'string', 'human_typed' => 'message', 'description' => 'Free-text message from the requester (sanitize_textarea_field, capped at MAX_USER_MESSAGE_LENGTH source-side). Empty string allowed.'],
257 'reply_email' => ['type' => 'string', 'human_typed' => 'contact', 'description' => 'Optional reply address (sanitize_email). Empty string = anonymous request.'],
258 'triggered_from' => [
259 'type' => 'string',
260 'enum' => [
261 'redirects_page',
262 'captured_404s_page',
263 'plugins_row_action',
264 'settings_debug',
265 'system_corrupt_install',
266 ],
267 'description' => 'Which admin surface launched the request. Pinned enum; mirror of Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES.',
268 ],
269 'debug_log_excerpt' => ['type' => 'string', 'description' => 'Best-effort sanitized debug-log tail, plus bounded PII-safe AJAX stage-trace JSONL AND AJAX request-checkpoint JSONL AND the detach A/B verdict AND receipt-reconstructed canary interpretation for the sending session, all assembled by SupportEvidenceExcerpt::assemble(), plus the browser transport-attempt buffer drained by SupportRequest.js (timings, byte counts, readyState, protocol; no URL, user text, or identifiers). Empty string when no source is available.'],
270 ];
271
272 return [
273 'error' => array_merge($base, $errorExtras),
274 'heartbeat' => array_merge($base, $heartbeatExtras),
275 'uninstall' => array_merge($base, $uninstallExtras),
276 'support_request' => array_merge($base, $supportRequestExtras),
277 ];
278 })();
279