feat(metrics): compiles and runs carry the editor BoardKind; ads learn to stay quiet

Several boards share one FQBN (the Pimoroni RP2350 trio all compile as
rpipico2), so FQBN-keyed metrics can't tell them apart. The compile
request and the run event now carry board_kind — analytics only, rides
into the metric hook's extra.

suppressOnlineOnlyAds(): overlay seam so the hosted build can silence
'available in the online editor' ad cards for items it manages — an
embargoed item must vanish, not advertise the site the user is on.
Pure OSS builds never call it; ads behave exactly as before.
This commit is contained in:
David Montero Crespo 2026-08-09 20:48:11 +02:00
parent f791536007
commit 8b2715affd
6 changed files with 46 additions and 4 deletions

View File

@ -163,6 +163,11 @@ class CompileRequest(BaseModel):
board_fqbn: str = "arduino:avr:uno"
# Optional: associate this compile with a project for analytics
project_id: str | None = None
# Optional: the editor's BoardKind (e.g. 'badger-2350'). Purely for
# analytics — several distinct boards can share one FQBN (the Pimoroni
# RP2350 boards all compile as rpipico2), and only the kind can tell
# them apart. Rides into the metric hook's `extra`.
board_kind: str | None = None
# Per-board ESP32 build options (Partition Scheme, CPU Freq, Flash Mode,
# PSRAM, etc.). Loose dict so the frontend can add fields without a
# backend deploy — espidf_compiler.compile validates known keys and
@ -514,6 +519,7 @@ async def _compile_job(
"has_wifi": response.has_wifi,
"async": True,
"initiated_by": request.initiated_by,
"board_kind": request.board_kind,
"partition_scheme": (request.board_options or {}).get("partitionScheme"),
"spiffs_file_count": len(request.spiffs_files or []),
},
@ -535,7 +541,7 @@ async def _compile_job(
success=False,
duration_ms=int((time.monotonic() - started) * 1000),
error_kind="exception",
extra={"file_count": len(files), "exception": str(exc)[:200], "async": True, "initiated_by": request.initiated_by},
extra={"file_count": len(files), "exception": str(exc)[:200], "async": True, "initiated_by": request.initiated_by, "board_kind": request.board_kind},
)
@ -568,7 +574,7 @@ async def compile_sketch(
success=False,
duration_ms=int((time.monotonic() - started) * 1000),
error_kind="exception",
extra={"file_count": len(files), "exception": str(e)[:200], "initiated_by": request.initiated_by},
extra={"file_count": len(files), "exception": str(e)[:200], "initiated_by": request.initiated_by, "board_kind": request.board_kind},
request=http_request,
)
raise HTTPException(status_code=500, detail=str(e))
@ -585,6 +591,7 @@ async def compile_sketch(
"file_count": len(files),
"has_wifi": response.has_wifi,
"initiated_by": request.initiated_by,
"board_kind": request.board_kind,
"partition_scheme": (request.board_options or {}).get("partitionScheme"),
"spiffs_file_count": len(request.spiffs_files or []),
},

View File

@ -43,6 +43,7 @@ import {
ONLINE_ONLY_BOARD_ADS,
ONLINE_ONLY_COMPONENT_ADS,
ONLINE_EDITOR_URL,
isOnlineOnlyAdSuppressed,
type OnlineOnlyBoardAd,
type OnlineOnlyComponentAd,
} from '../lib/onlineOnlyBoards';
@ -293,6 +294,7 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
return ONLINE_ONLY_COMPONENT_ADS.filter(
(ad) =>
!registry.getById(ad.id) &&
!isOnlineOnlyAdSuppressed(ad.id) &&
(selectedCategory === 'all' || ad.category === selectedCategory) &&
(!q || ad.label.toLowerCase().includes(q)),
);
@ -858,7 +860,10 @@ const BoardCard: React.FC<BoardCardProps> = ({ kind, onSelect, hoverApi }) => {
// Hidden automatically in any build that registers the real BoardKind.
/** Recomputed on access (not module load): overlay board registration patches
* BOARD_KIND_LABELS at mount, which must hide the corresponding ad. */
const visibleBoardAds = () => ONLINE_ONLY_BOARD_ADS.filter((ad) => !(ad.id in BOARD_KIND_LABELS));
const visibleBoardAds = () =>
ONLINE_ONLY_BOARD_ADS.filter(
(ad) => !(ad.id in BOARD_KIND_LABELS) && !isOnlineOnlyAdSuppressed(ad.id),
);
/** Teal "ONLINE" pill: the board runs (free) in the hosted editor. */
const OnlineBadge: React.FC = () => (

View File

@ -234,6 +234,7 @@ export const EditorToolbar = ({
void reportRunEvent({
project_id: currentProject?.id ?? null,
board_fqbn: fqbn ?? null,
board_kind: boardKind ?? null,
engine: engine ?? null,
});
},
@ -571,6 +572,7 @@ export const EditorToolbar = ({
{
boardOptions: activeBoard?.boardOptions,
spiffsFiles: activeBoard?.spiffsFiles,
boardKind: kind ?? undefined,
// P2.4 — THIS board's declared manifest (compile scope). Per-board so
// two boards can use different libraries without clashing.
libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null,
@ -1077,7 +1079,7 @@ export const EditorToolbar = ({
})),
]);
},
{ boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined },
{ boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, boardKind: board.boardKind, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined },
);
const resultLogs = parseCompileResult(result, label, boardTarget, lastStreamedLen > 0);

View File

@ -25,6 +25,27 @@ export interface OnlineOnlyBoardAd {
/** Where the ad cards send the user. */
export const ONLINE_EDITOR_URL = 'https://velxio.com';
/**
* Ad suppression — the hosted overlay's escape hatch.
*
* The "ad disappears when the real thing registers" contract breaks down in
* the hosted build itself: an item the overlay knows about but chose NOT to
* register (launch embargo) must not fall back to an ad card that says
* "available in the online editor" — the user is already IN the online
* editor. The overlay calls this once at mount with every id it manages;
* items it registers replace their ad anyway, items it embargoes simply
* vanish. Pure OSS builds never call it, so ads behave exactly as before.
*/
const suppressedAdIds = new Set<string>();
export function suppressOnlineOnlyAds(ids: string[]): void {
for (const id of ids) suppressedAdIds.add(id);
}
export function isOnlineOnlyAdSuppressed(id: string): boolean {
return suppressedAdIds.has(id);
}
export const ONLINE_ONLY_BOARD_ADS: OnlineOnlyBoardAd[] = [
{
id: 'esp32-c6',

View File

@ -27,6 +27,10 @@ export interface CompileExtras {
// Who triggered the compile — 'agent' when the AI assistant's tool did.
// Threads through to backend metrics; omitted = manual user action.
initiatedBy?: 'agent';
// The editor's BoardKind — analytics only. Distinct boards can share one
// FQBN (Pimoroni RP2350 boards all compile as rpipico2); the kind is the
// only identifier that tells them apart in the metrics.
boardKind?: string;
}
export interface CompileResult {
@ -122,6 +126,7 @@ export async function compileCode(
project_id: projectId ?? null,
board_options,
spiffs_files,
board_kind: extras?.boardKind ?? null,
libraries,
language: extras?.language ?? null,
initiated_by: extras?.initiatedBy ?? null,

View File

@ -12,6 +12,8 @@ api.interceptors.request.use((config) => {
export interface RunEventPayload {
project_id?: string | null;
board_fqbn?: string | null;
/** Editor BoardKind — disambiguates boards that share an FQBN (analytics). */
board_kind?: string | null;
/** Which engine took this run — 'instant' (browser) or 'linux' (guest).
* Absent for boards where the question doesn't apply. */
engine?: 'instant' | 'linux' | null;