@avada-falcon/aeo-measure-client
v0.13.0
Published
Client for the AEO measurement service: typed calls, three-state rendering, webhook verification.
Readme
@avada-falcon/aeo-measure-client
Published on npmjs.com, public, in the avada-falcon org:
npm install @avada-falcon/aeo-measure-client. To release, bump version in this folder's
package.json, merge to master and push the tag client-v<version>: the CI job publish_client
publishes it (prepack builds lib/ and types/).
CommonJS (lib/) + declarations (types/), the same layout as @avada/utils, because AEO's
functions are compiled by Babel to CommonJS. No runtime dependencies (Node ≥ 18.17).
What it is for
Wrapping twelve endpoints is not the reason. fetch does that. It exists for the three
things that break at the consuming app, where the service cannot protect them:
| | |
|---|---|
| Three-state measures | Read a Measure with render() / percent() / isMeasured(), never .rate. m.rate ?? 0 prints 0% for a brand that was never measured. |
| Webhook verification | Constant-time, replay window, secret rotation. Getting one of those wrong accepts forged events. |
| Tenant boundary | app is fixed when the client is created. Calling code passes only its own shop id and cannot address a shop through another app. |
In AEO
// packages/functions/src/services/aeoMeasure.js
import {createClient} from '@avada-falcon/aeo-measure-client';
export const measure = createClient({
baseUrl: process.env.AEO_MEASURE_URL,
apiKey: process.env.AEO_MEASURE_KEY, // `npm run key -- aeo` on the service
app: 'aeo'
});// packages/functions/src/controllers/aeoMeasureController.js
import {getCurrentShop} from '@functions/helpers/auth';
import {render, percent, isMeasured, ConflictError} from '@avada-falcon/aeo-measure-client';
import {measure} from '@functions/services/aeoMeasure';
export async function getVisibility(ctx) {
const shopId = getCurrentShop(ctx); // never from the request
const m = await measure.metrics(shopId);
return (ctx.body = {
label: render(m.visibility), // "78.2% (n=64)" (engines weighted) or "Not measured — 1/3 cycles"
percent: percent(m.visibility), // number, or null when not measured
answers: isMeasured(m.visibility) ? m.visibility.count : null // real answers behind it
});
}
export async function startMeasurement(ctx) {
const shopId = getCurrentShop(ctx);
try {
return (ctx.body = await measure.startCycle(shopId, {newCycle: true}));
} catch (err) {
// cycle_running or too_soon. There is no monthly cap to hit (0.7.0: no 402 any more).
if (err instanceof ConflictError) {
ctx.status = 409;
return (ctx.body = {code: err.code, message: err.message});
}
throw err;
}
}0.11.0
trends(ref): the visibility trend of every measured question set (active and retired, newest first), each computed on its own set asmetrics(ref, { series })would. For a chart with one line per set.
0.10.0
createDraft(ref, { from }): start the draft from an earlier question set (uuid or version) instead of the active one, to measure with it again. It replaces an open draft; activating it makes a new version.NotFoundErrorfor a set the workspace does not have.
0.9.1
TopicTemplategainsmatches: string[]: lower-case words that identify the industry in a category text. Match a category to a template onlabelor anymatchesentry, at word boundaries (servicedocs/api.md,GET /v1/topic-templates).
0.9.0
- An error body that is not JSON (a proxy's 502/504 HTML page) throws
ApiErrorwithbody: { message: <the text> }, not aSyntaxError. discardDraft()andsaveTopics()are no longer retried. A retry after a lost 204 threwNotFoundErrorfor a draft that was already gone, and a retriedsaveTopics()re-ran the whole re-parse. After a timeout, readtopics()orprompts()before sending it again.webhooks.register()no longer declaresactive, which that response never carried.- Service side, no client change:
sources.ownSiteCitednow counts the store's subdomains too (ir.allbirds.comforallbirds.com), so it can read higher than before.
0.8.0
- Engines are combined with fixed weights: ChatGPT, asked once a question after its first scan,
keeps the weight of a full scan (service
docs/api.md, "How engines are combined"). Measure(measured) gainscount, the real answers behind the rate, andweighted. Whenweightedis true, numerator/denominator are weighted sums: show the rate withcount, not as a fraction.render()prints78.2% (n=64)for it.byEnginestays unweighted.TrendPointgainsrate,countandweighted: plotrate, don't divide.shareTrend.points[].rows[]gainsrate, the share by weight (null when no brand was named).sentiment.byEntity[]gainssplit: positive/neutral/negative as Measures, by weight.
0.7.0
quote():budget,fitsBudgetandshortfallare gone; the service enforces no monthly cap. An engine's first scan of a question set can ask more often than its later scans, so each engine line gainsfirstScan,samples,laterandperMonth, and the quote gainsperLaterCycle.answers,usd,liveUsdandperCycleare the NEXT scan.BudgetExceededErroris deprecated: the service no longer answers 402.
Receiving webhooks
// packages/functions/src/controllers/aeoMeasureWebhookController.js
import {verifyWebhook, WebhookVerificationError} from '@avada-falcon/aeo-measure-client';
import publishTopic from '@functions/helpers/pubsub/publishTopic';
export async function onMeasureEvent(ctx) {
let event;
try {
event = verifyWebhook({
// RAW body. On Firebase Functions it lives on the Node request — the same
// `ctx.req.rawBody` AEO's middleware/validateAction.js already signs against.
// A re-serialised JSON body will not verify.
payload: ctx.req.rawBody,
headers: ctx.headers,
secret: process.env.AEO_MEASURE_WEBHOOK_SECRET
});
} catch (err) {
if (err instanceof WebhookVerificationError) return (ctx.status = 401);
throw err;
}
// Deliveries retry until acknowledged, so the same event can arrive twice.
// Deduplicate on event.id, acknowledge fast, and do the work on Pub/Sub.
// Publish the REFERENCE, not a payload: the subscriber calls measure.metrics().
await publishTopic('aeoMeasureCycleSettled', {
eventId: event.id,
shopId: event.data.workspace.ref,
cycleId: event.data.cycle.id
});
return (ctx.status = 204);
}For a caller that can itself redeliver — a Pub/Sub subscriber calling startCycle — pass
idempotencyKey: message.id, or every redelivery looks like a new request.
