Provenance
EcosystemAuto-Instrumentation

Resolution

How resourceType, action, and resourceId are resolved from HTTP requests, ORM operations, and message queues.

Every interaction needs a resourceType, action, and resourceId. The auto-instrumentation resolves each one through a chain — first match wins:

  1. resolve() function — full override / escape hatch
  2. Route map / model map / topic map — declarative config
  3. Convention-based inference — defaults
  4. Fallback — generic type

Convention defaults

With zero configuration, the auto-instrumentation infers everything from the request:

SourceresourceTypeaction
POST /api/ordersORDERCREATE
GET /api/users/123USERREAD
PUT /api/orders/456ORDERUPDATE
DELETE /api/items/789ITEMDELETE
PATCH /api/settingsSETTINGUPDATE
PUT /api/orders/1/items/2ITEMUPDATE
POST /api/users/1/postsPOSTCREATE

The conventions are:

  • resourceType — extracted from the last meaningful URL segment, singularized, and converted to UPPER_SNAKE_CASE. /api/ordersORDER, /api/orders/123/items/456ITEM
  • action — mapped from the HTTP method: GET→READ, POST→CREATE, PUT→UPDATE, PATCH→UPDATE, DELETE→DELETE
  • resourceId — extracted from the last route param (req.params.itemId), response body (result.id), or request body (body.id)

Segments that look like IDs (UUIDs, numbers) or common prefixes (api, v1, v2) are skipped automatically.

Nested routes

Nested routes like /api/orders/:orderId/items/:itemId are handled automatically:

  • resourceType resolves to the leaf resource — ITEM, not ORDER
  • resourceId resolves to the last param — itemId, not orderId
  • parent context is extracted and included in the interaction metadata
PUT /api/orders/ord-1/items/item-2

→ resourceType: ITEM
→ action:       UPDATE
→ resourceId:   item-2
→ interaction.parent: { resourceType: "ORDER", resourceId: "ord-1" }

This works for any nesting depth:

DELETE /api/orders/1/items/2/variants/3

→ resourceType: VARIANT
→ action:       DELETE
→ resourceId:   3
→ interaction.parent: { resourceType: "ORDER", resourceId: "1" }

The parent is always the top-level resource in the path. For deeply nested routes, intermediate resources are not tracked as separate parents — use the route map if you need more granular control.

If you need to override the nested convention, the route map takes priority:

instrument({
  // ...
  routes: {
    'POST /api/orders/:orderId/items/:itemId/return': {
      resourceType: 'RETURN',
      action: 'CREATE',
    },
  },
  resourceId: {
    RETURN: (ctx) => ctx.params.itemId,
  },
});

Route map

When your domain actions don't match HTTP verbs, use the route map:

instrument({
  apiKey: process.env.PROVENANCE_API_KEY,
  origin: 'order-service',

  routes: {
    'POST /api/orders':              { resourceType: 'ORDER', action: 'PLACE' },
    'POST /api/orders/:id/cancel':   { resourceType: 'ORDER', action: 'CANCEL' },
    'POST /api/orders/:id/ship':     { resourceType: 'ORDER', action: 'SHIP' },
    'POST /api/orders/:id/refund':   { resourceType: 'ORDER', action: 'REFUND' },
    'GET /api/orders/:id/invoice':   { resourceType: 'INVOICE', action: 'GENERATE' },
  },
});

Without the route map, POST /api/orders/:id/refund would resolve to resourceType: ORDER, action: CREATE (because POST). The override gives you the domain-specific REFUND action.

Routes not in the map still use convention defaults — you only need to override the ones that don't fit.

Model map

Override how ORM model names map to resource types:

instrument({
  // ...
  models: {
    'User':          { resourceType: 'CUSTOMER' },       // Prisma model → your domain type
    'OrderLineItem': { resourceType: 'ORDER_LINE' },
    'AuditLog':      null,                                // null = don't track this model
  },
});

Without the model map, prisma.user.create() would resolve to resourceType: USER. The override maps it to CUSTOMER.

Action map

Override the default HTTP verb → action mapping:

instrument({
  // ...
  actions: {
    'PATCH': 'MODIFY',    // instead of default UPDATE
    'POST': 'SUBMIT',     // instead of default CREATE
  },
});

Topic map

Override how message queue topics resolve (for future Kafka/AMQP patches):

instrument({
  // ...
  topics: {
    'order.created':  { resourceType: 'ORDER', action: 'NOTIFY' },
    'order.*':        { resourceType: 'ORDER' },  // wildcard — action defaults to PUBLISH/CONSUME
    'audit.log':      null,                        // null = don't track
  },
});

Outbound map

Override how outbound HTTP calls resolve by hostname:

instrument({
  // ...
  outbound: {
    'payment-service':   { resourceType: 'PAYMENT', action: 'PROCESS' },
    'notification-svc':  { resourceType: 'NOTIFICATION', action: 'SEND' },
  },
});

Without the outbound map, a POST to http://payment-service:4001/api/charges would resolve to resourceType: CHARGE, action: CREATE. The override gives you PAYMENT, PROCESS.

Resource ID extraction

Control how the resource ID is extracted for each resource type:

instrument({
  // ...
  resourceId: {
    // Per resource type
    'ORDER':    (ctx) => ctx.params?.id || ctx.result?.orderId,
    'CUSTOMER': (ctx) => ctx.user?.id,
    'INVOICE':  (ctx) => `inv-${ctx.params?.id}`,  // derived ID

    // Default (applies when no type-specific rule matches)
    '*': (ctx) => ctx.params?.id || ctx.result?.id || ctx.body?.id,
  },
});

Context object

The ctx object passed to every resolver contains:

FieldTypeDescription
methodstringHTTP method (GET, POST, etc.)
pathstringRequest path (/api/orders/order-501)
routestringExpress/Fastify route pattern (/api/orders/:id)
paramsobjectRoute params ({ id: 'order-501' })
bodyanyParsed request body
headersobjectRequest headers
useranyreq.user (if auth middleware ran)
statusCodenumberResponse status code
resultanyParsed response body
parentobjectParent resource from nested routes ({ resourceType, resourceId })
modelstringORM model name (Prisma context only)
operationstringORM operation (Prisma context only)
topicstringMessage topic (queue context only)
directionstringpublish or consume (queue context only)
hostnamestringTarget hostname (outbound context only)

Full override

The resolve() function is the escape hatch — it runs first and can override everything or skip tracking entirely:

instrument({
  // ...
  resolve: (ctx) => {
    // Skip internal endpoints
    if (ctx.path === '/api/internal/health') return null;

    // Custom logic for admin routes
    if (ctx.path.startsWith('/api/admin')) {
      return { resourceType: 'ADMIN_ACTION', action: 'EXECUTE' };
    }

    // Return undefined to fall through to route map / conventions
  },
});
  • Return an object to override ({ resourceType, action, resourceId } — all optional, missing fields filled by convention)
  • Return null to skip tracking for this request
  • Return undefined (or nothing) to fall through to the next resolution step

Excluding paths

Skip paths that shouldn't be tracked:

instrument({
  // ...
  exclude: [
    '/health',
    '/metrics',
    '/api-docs',
    'GET /api/config',  // specific method + path
  ],
});

Exclusions match by prefix — /api-docs also excludes /api-docs/swagger.json.

Summary

The principle:

  • 80% of routes work with zero config — conventions handle it
  • 15% need a one-liner in the route/model map
  • 5% need a resolver function for complex logic
  • null in any map means "don't track this" — explicit opt-out
PrioritySourceExample
1resolve() functionCustom logic, skip tracking
2routes / models / topics / outbound mapsDeclarative overrides
3ConventionPath segment → type, HTTP verb → action
4FallbackUNKNOWN type