From 29d78de95e045ddec95fb1e1ffe9215b6e7f119e Mon Sep 17 00:00:00 2001 From: drapeau Date: Fri, 22 May 2026 19:15:16 +0000 Subject: [PATCH] sdk: add report resource for agent observability Add IReportResource interface and ReportResource implementation that POSTs to /agent_observations. Supports reporting purchase outcomes (success, blocked, abandoned) with domain, tags, and context. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/sdk/src/index.ts | 1 + .../src/resources/__tests__/report.test.ts | 162 ++++++++++++++++++ packages/sdk/src/resources/interfaces.ts | 22 +++ packages/sdk/src/resources/report.ts | 124 ++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 packages/sdk/src/resources/__tests__/report.test.ts create mode 100644 packages/sdk/src/resources/report.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 06ad87f..98dc66a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,6 +10,7 @@ export * from './resources/payment-methods'; export * from './resources/shipping-address'; export * from './resources/user-info'; export * from './resources/web-bot-auth'; +export * from './resources/report'; export { MemoryStorage, Storage, storage } from './utils/storage'; export type { AuthStorage, diff --git a/packages/sdk/src/resources/__tests__/report.test.ts b/packages/sdk/src/resources/__tests__/report.test.ts new file mode 100644 index 0000000..9d45893 --- /dev/null +++ b/packages/sdk/src/resources/__tests__/report.test.ts @@ -0,0 +1,162 @@ +import { LinkApiError, LinkTransportError } from '@/errors'; +import type { CreateReportParams, ReportRecord } from '@/resources/interfaces'; +import { ReportResource } from '@/resources/report'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +const getAccessToken = vi.fn(); + +function mockFetchResponse(status: number, body: object) { + mockFetch.mockResolvedValue({ + status, + text: async () => JSON.stringify(body), + }); +} + +const validParams: CreateReportParams = { + domain: 'merchant.com', + outcome: 'blocked', + spend_request_id: 'lsrq_test123', + tags: ['captcha', 'cdn_block'], + step: 'checkout payment form', + freeform_context: 'Challenge appeared after clicking Place Order', +}; + +const successResponse: ReportRecord = { + object: 'agent_report', + created_at: '2026-05-20T18:30:00Z', + domain: 'merchant.com', + outcome: 'blocked', + spend_request_id: 'lsrq_test123', + status: 'received', +}; + +describe('ReportResource', () => { + let resource: ReportResource; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + vi.clearAllMocks(); + getAccessToken.mockResolvedValue('test_token'); + resource = new ReportResource({ getAccessToken }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('create', () => { + it('sends POST to /agent_observations with JSON body and Bearer auth', async () => { + mockFetchResponse(201, successResponse); + + await resource.create(validParams); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.link.com/agent_observations'); + expect(opts.method).toBe('POST'); + expect(opts.headers['Content-Type']).toBe('application/json'); + expect(opts.headers.Authorization).toBe('Bearer test_token'); + expect(JSON.parse(opts.body)).toEqual(validParams); + }); + + it('returns the report record on success', async () => { + mockFetchResponse(201, successResponse); + + const result = await resource.create(validParams); + + expect(result).toEqual(successResponse); + }); + + it('works with only required params', async () => { + mockFetchResponse(201, successResponse); + + await resource.create({ + domain: 'shop.example.com', + outcome: 'success', + spend_request_id: 'lsrq_minimal', + }); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.domain).toBe('shop.example.com'); + expect(body.outcome).toBe('success'); + expect(body.spend_request_id).toBe('lsrq_minimal'); + expect(body.tags).toBeUndefined(); + expect(body.step).toBeUndefined(); + expect(body.freeform_context).toBeUndefined(); + }); + + it('retries with refreshed token on 401', async () => { + mockFetch + .mockResolvedValueOnce({ status: 401, text: async () => '{}' }) + .mockResolvedValueOnce({ + status: 201, + text: async () => JSON.stringify(successResponse), + }); + getAccessToken + .mockResolvedValueOnce('expired_token') + .mockResolvedValueOnce('fresh_token'); + + const result = await resource.create(validParams); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const [, secondOpts] = mockFetch.mock.calls[1]; + expect(secondOpts.headers.Authorization).toBe('Bearer fresh_token'); + expect(result).toEqual(successResponse); + }); + + it('throws LinkApiError on 400 with error message', async () => { + mockFetchResponse(400, { + error: { + message: 'outcome must be one of: success, blocked, abandoned', + }, + }); + + const err = await resource.create(validParams).catch((e) => e); + + expect(err).toBeInstanceOf(LinkApiError); + expect(err.message).toMatch('Failed to create report (400)'); + expect(err.message).toMatch('outcome must be one of'); + }); + + it('throws LinkApiError on 404 when flag disabled', async () => { + mockFetchResponse(404, { error: { message: 'Not found' } }); + + const err = await resource.create(validParams).catch((e) => e); + + expect(err).toBeInstanceOf(LinkApiError); + expect(err.message).toMatch('Failed to create report (404)'); + }); + + it('throws when access token is unavailable', async () => { + getAccessToken.mockRejectedValueOnce(new Error('Not authenticated')); + + await expect(resource.create(validParams)).rejects.toThrow( + 'Not authenticated', + ); + }); + + it('throws LinkTransportError on network failure', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const err = await resource.create(validParams).catch((e) => e); + + expect(err).toBeInstanceOf(LinkTransportError); + expect(err.message).toMatch('Request failed'); + }); + + it('throws LinkApiError with raw body when error response is not structured', async () => { + mockFetch.mockResolvedValueOnce({ + status: 500, + text: async () => 'Internal Server Error', + }); + + const err = await resource.create(validParams).catch((e) => e); + + expect(err).toBeInstanceOf(LinkApiError); + expect(err.message).toMatch('Failed to create report (500)'); + expect(err.message).toMatch('Internal Server Error'); + }); + }); +}); diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 7973b26..45ac4fc 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -91,3 +91,25 @@ export interface IUserInfoResource { export interface IWebBotAuthResource { getHeaders(url: string): Promise; } + +export interface CreateReportParams { + domain: string; + outcome: 'success' | 'blocked' | 'abandoned'; + spend_request_id: string; + tags?: string[]; + step?: string; + freeform_context?: string; +} + +export interface ReportRecord { + object: string; + created_at: string; + domain: string; + outcome: string; + spend_request_id: string; + status: string; +} + +export interface IReportResource { + create(params: CreateReportParams): Promise; +} diff --git a/packages/sdk/src/resources/report.ts b/packages/sdk/src/resources/report.ts new file mode 100644 index 0000000..1f94b02 --- /dev/null +++ b/packages/sdk/src/resources/report.ts @@ -0,0 +1,124 @@ +import { + type LinkOptions, + requireFetchImplementation, + resolveLinkSdkConfig, +} from '@/config'; +import { LinkApiError, LinkTransportError } from '@/errors'; +import type { + AccessTokenProvider, + CreateReportParams, + IReportResource, + ReportRecord, +} from '@/resources/interfaces'; + +interface ApiFetchOptions { + method: string; + url: string; + headers?: Record; + body?: string; +} + +export class ReportResource implements IReportResource { + private readonly verbose: boolean; + private readonly getAccessToken: AccessTokenProvider; + private readonly fetchImpl: typeof globalThis.fetch; + private readonly endpoint: string; + private readonly logger: { debug(message: string): void }; + + constructor(options: LinkOptions) { + const config = resolveLinkSdkConfig(options); + this.verbose = config.verbose; + this.getAccessToken = config.getAccessToken; + this.fetchImpl = requireFetchImplementation(config); + this.endpoint = `${config.apiBaseUrl}/agent_observations`; + this.logger = config.logger; + } + + private async rawFetch( + opts: ApiFetchOptions, + ): Promise<{ status: number; data: unknown; rawBody: string }> { + if (this.verbose) { + const redactedHeaders = { ...opts.headers }; + if (redactedHeaders.Authorization) + redactedHeaders.Authorization = 'Bearer '; + this.logger.debug(`> ${opts.method} ${opts.url}`); + this.logger.debug(` Headers: ${JSON.stringify(redactedHeaders)}`); + if (opts.body) this.logger.debug(opts.body); + } + + let response: Response; + try { + response = await this.fetchImpl(opts.url, { + method: opts.method, + headers: opts.headers, + body: opts.body, + }); + } catch (error) { + throw new LinkTransportError( + `Request failed: ${opts.method} ${opts.url}`, + { cause: error }, + ); + } + const rawBody = await response.text(); + + let data: unknown = null; + try { + data = JSON.parse(rawBody); + } catch { + // non-JSON response + } + + if (this.verbose) { + this.logger.debug(`< ${response.status} ${response.statusText}`); + this.logger.debug(JSON.stringify(data, null, 2) ?? rawBody); + } + + return { status: response.status, data, rawBody }; + } + + private async apiFetch( + opts: ApiFetchOptions, + ): Promise<{ status: number; data: unknown; rawBody: string }> { + const token = await this.getAccessToken(); + const authedOpts = { + ...opts, + headers: { ...opts.headers, Authorization: `Bearer ${token}` }, + }; + + const res = await this.rawFetch(authedOpts); + + if (res.status === 401) { + const refreshedToken = await this.getAccessToken({ forceRefresh: true }); + authedOpts.headers.Authorization = `Bearer ${refreshedToken}`; + return this.rawFetch(authedOpts); + } + + return res; + } + + async create(params: CreateReportParams): Promise { + const { status, data, rawBody } = await this.apiFetch({ + method: 'POST', + url: this.endpoint, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + + if (status < 200 || status >= 300) { + const message = + data && + typeof data === 'object' && + 'error' in data && + typeof (data as Record).error === 'object' + ? ((data as Record).error?.message ?? + rawBody) + : rawBody; + throw new LinkApiError( + `Failed to create report (${status}): ${message}`, + { status, rawBody, details: data }, + ); + } + + return data as ReportRecord; + } +}