@useairfoil/producer-google-analytics 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -2
- package/dist/index.d.mts +773 -0
- package/dist/index.mjs +3 -0
- package/dist/main.d.mts +1 -0
- package/dist/main.mjs +662 -0
- package/dist/main.mjs.map +1 -0
- package/dist/manifest.d.mts +45 -0
- package/dist/manifest.mjs +75 -0
- package/dist/manifest.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/src-Cu0LuOTi.mjs +711 -0
- package/dist/src-Cu0LuOTi.mjs.map +1 -0
- package/package.json +48 -4
- package/src/client/auth.ts +133 -0
- package/src/client/client.ts +212 -0
- package/src/connector.ts +55 -0
- package/src/create-table.ts +5 -0
- package/src/demo/index.ts +44 -0
- package/src/demo/reports.ts +251 -0
- package/src/demo/shared.ts +98 -0
- package/src/index.ts +26 -0
- package/src/main.ts +22 -0
- package/src/manifest.ts +50 -0
- package/src/resources/reports/read.ts +104 -0
- package/src/resources/reports/resource.ts +31 -0
- package/src/resources/reports/sources.ts +169 -0
- package/src/resources/reports/spec.ts +15 -0
- package/src/resources/reports/specs.ts +173 -0
- package/src/sandbox.ts +37 -0
- package/src/schemas/api.ts +37 -0
- package/src/schemas/reports.ts +172 -0
- package/src/schemas/shared.ts +42 -0
- package/src/start.ts +49 -0
- package/src/tables.ts +25 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"src-Cu0LuOTi.mjs","names":["make","layer","make","GoogleAnalyticsAuth.GoogleAnalyticsAuth","layer","GoogleAnalyticsAuth.layer","layerConfig","GoogleAnalyticsClient.GoogleAnalyticsClient","GoogleAnalyticsClient.layer"],"sources":["../src/client/auth.ts","../src/schemas/api.ts","../src/client/client.ts","../src/schemas/shared.ts","../src/resources/reports/read.ts","../src/resources/reports/sources.ts","../src/resources/reports/resource.ts","../src/schemas/reports.ts","../src/resources/reports/specs.ts","../src/connector.ts"],"sourcesContent":["import { Auth, ConnectorError, Telemetry } from \"@useairfoil/connector-kit\";\nimport { Clock, Context, Duration, Effect, Layer, Redacted, Schedule, Schema } from \"effect\";\nimport { HttpClient, HttpClientRequest, HttpClientResponse } from \"effect/http\";\nimport { createPrivateKey, sign } from \"node:crypto\";\n\nimport type { GoogleAnalyticsConfig } from \"../manifest\";\n\ntype GoogleAnalyticsAuthConfig = Pick<GoogleAnalyticsConfig, \"clientEmail\" | \"privateKey\">;\n\nexport type GoogleAnalyticsAuthService = Auth.TokenCache<ConnectorError>;\n\nexport class GoogleAnalyticsAuth extends Context.Service<\n GoogleAnalyticsAuth,\n GoogleAnalyticsAuthService\n>()(\"@useairfoil/producer-google-analytics/GoogleAnalyticsAuth\") {}\n\nexport const tokenUrl = \"https://oauth2.googleapis.com/token\";\nexport const scope = \"https://www.googleapis.com/auth/analytics.readonly\";\n\nconst TokenResponseSchema = Schema.Struct({\n access_token: Schema.String,\n expires_in: Schema.Number,\n});\n\nconst TokenErrorSchema = Schema.Struct({\n error: Schema.optional(Schema.String),\n error_description: Schema.optional(Schema.String),\n});\n\nconst base64Url = (value: string | Buffer): string => Buffer.from(value).toString(\"base64url\");\n\n/** Signs the service account JWT with RS256. Google allows at most 1 hour. */\nexport const makeAssertion = (options: {\n readonly clientEmail: string;\n readonly privateKey: string;\n readonly nowMillis: number;\n}): Effect.Effect<string, ConnectorError> =>\n Effect.try({\n try: () => {\n const now = Math.floor(options.nowMillis / 1000);\n const header = base64Url(JSON.stringify({ alg: \"RS256\", typ: \"JWT\" }));\n const claims = base64Url(\n JSON.stringify({\n iss: options.clientEmail,\n scope,\n aud: tokenUrl,\n iat: now,\n exp: now + 3600,\n }),\n );\n const signingInput = `${header}.${claims}`;\n // Env files often keep the PEM on one line with escaped newlines.\n const key = createPrivateKey(options.privateKey.replace(/\\\\n/g, \"\\n\"));\n return `${signingInput}.${base64Url(sign(\"sha256\", Buffer.from(signingInput), key))}`;\n },\n catch: (cause) =>\n new ConnectorError({ message: \"Invalid Google service account private key\", cause }),\n });\n\nconst tokenError = (response: HttpClientResponse.HttpClientResponse) =>\n HttpClientResponse.schemaBodyJson(TokenErrorSchema)(response).pipe(\n Effect.map((body) => body.error),\n Effect.orElseSucceed(() => undefined),\n Effect.flatMap((error) =>\n Effect.fail(\n new ConnectorError({\n message:\n error === \"invalid_grant\" || error === \"invalid_client\"\n ? \"Google rejected the service account key\"\n : `Google returned ${response.status} for the OAuth token`,\n }),\n ),\n ),\n );\n\nexport const make = Effect.fnUntraced(function* (config: GoogleAnalyticsAuthConfig) {\n const client = (yield* HttpClient.HttpClient).pipe(\n HttpClient.retryTransient({\n schedule: Schedule.exponential(\"250 millis\").pipe(Schedule.jittered),\n times: 3,\n }),\n );\n\n return yield* Auth.makeTokenCache({\n acquire: Effect.gen(function* () {\n const assertion = yield* makeAssertion({\n clientEmail: config.clientEmail,\n privateKey: Redacted.value(config.privateKey),\n nowMillis: yield* Clock.currentTimeMillis,\n });\n const response = yield* client\n .execute(\n HttpClientRequest.post(tokenUrl).pipe(\n HttpClientRequest.acceptJson,\n HttpClientRequest.bodyUrlParams({\n grant_type: \"urn:ietf:params:oauth:grant-type:jwt-bearer\",\n assertion,\n }),\n ),\n )\n .pipe(\n Effect.mapError(\n (cause) => new ConnectorError({ message: \"Google OAuth token request failed\", cause }),\n ),\n );\n const body = yield* HttpClientResponse.matchStatus(response, {\n \"2xx\": (ok) =>\n HttpClientResponse.schemaBodyJson(TokenResponseSchema)(ok).pipe(\n Effect.mapError(\n (cause) =>\n new ConnectorError({ message: \"Invalid Google OAuth token response\", cause }),\n ),\n ),\n orElse: tokenError,\n });\n return {\n value: Redacted.make(body.access_token),\n expiresIn: Duration.seconds(body.expires_in),\n };\n }).pipe(\n Effect.tapError((error) => Telemetry.annotateError(\"api_auth\", error)),\n Effect.withSpan(Telemetry.SpanName.apiFetch, {\n kind: \"client\",\n attributes: { [Telemetry.Attr.apiPath]: \"/token\" },\n }),\n ),\n });\n});\n\nexport const layer = (\n config: GoogleAnalyticsAuthConfig,\n): Layer.Layer<GoogleAnalyticsAuth, never, HttpClient.HttpClient> =>\n Layer.effect(GoogleAnalyticsAuth)(make(config));\n","import { Schema } from \"effect\";\n\nconst ValueSchema = Schema.Struct({ value: Schema.String });\n\n/** `POST properties/{id}:runReport`. Every value comes as a string. */\nexport const RunReportResponseSchema = Schema.Struct({\n dimensionHeaders: Schema.Array(Schema.Struct({ name: Schema.String })),\n metricHeaders: Schema.Array(Schema.Struct({ name: Schema.String })),\n rows: Schema.optional(\n Schema.Array(\n Schema.Struct({\n dimensionValues: Schema.Array(ValueSchema),\n metricValues: Schema.Array(ValueSchema),\n }),\n ),\n ),\n // Left out when the report is empty.\n rowCount: Schema.optional(Schema.Int),\n metadata: Schema.Struct({\n timeZone: Schema.String,\n currencyCode: Schema.optional(Schema.String),\n dataLossFromOtherRow: Schema.optional(Schema.Boolean),\n subjectToThresholding: Schema.optional(Schema.Boolean),\n samplingMetadatas: Schema.optional(Schema.Array(Schema.Unknown)),\n dataTruncationReasons: Schema.optional(Schema.Array(Schema.Unknown)),\n }),\n});\n\nexport type RunReportResponse = Schema.Schema.Type<typeof RunReportResponseSchema>;\n\nexport const ErrorBodySchema = Schema.Struct({\n error: Schema.Struct({\n code: Schema.optional(Schema.Int),\n message: Schema.optional(Schema.String),\n status: Schema.optional(Schema.String),\n }),\n});\n","import { ConnectorError, Metrics, Telemetry } from \"@useairfoil/connector-kit\";\nimport {\n Config,\n Context,\n Data,\n DateTime,\n Duration,\n Effect,\n Exit,\n Layer,\n Redacted,\n Schedule,\n Semaphore,\n} from \"effect\";\nimport { HttpClient, HttpClientRequest, HttpClientResponse } from \"effect/http\";\n\nimport { type GoogleAnalyticsConfig, manifest } from \"../manifest\";\nimport { ErrorBodySchema, type RunReportResponse, RunReportResponseSchema } from \"../schemas/api\";\nimport * as GoogleAnalyticsAuth from \"./auth\";\n\nexport const DATA_API_VERSION = \"v1beta\";\n\nconst baseUrl = `https://analyticsdata.googleapis.com/${DATA_API_VERSION}`;\nconst maxRetries = 5;\nconst retryBaseDelay = Duration.seconds(1);\nconst requestTimeout = Duration.minutes(2);\n// Token quotas refill within the hour, or at midnight Pacific for the daily one.\n// Keep waiting; a failed source stays stopped until restart.\nconst limitRetry = Schedule.min([Schedule.exponential(\"1 minute\"), Schedule.spaced(\"10 minutes\")]);\n// A standard property allows 10 requests at once. Leave room for other tools.\nconst maxConcurrent = 5;\n\nexport type ReportRequest = {\n readonly dimensions: ReadonlyArray<string>;\n readonly metrics: ReadonlyArray<string>;\n /** `YYYY-MM-DD` in the property's time zone. */\n readonly startDate: string;\n readonly endDate: string;\n readonly limit: number;\n readonly offset: number;\n};\n\nexport type GoogleAnalyticsClientService = {\n /** `name` labels the span and errors, such as the resource name. */\n readonly runReport: (\n name: string,\n request: ReportRequest,\n ) => Effect.Effect<RunReportResponse, ConnectorError>;\n /** The property's time zone. Report dates are days in this zone. */\n readonly timeZone: Effect.Effect<DateTime.TimeZone, ConnectorError>;\n};\n\nexport class GoogleAnalyticsClient extends Context.Service<\n GoogleAnalyticsClient,\n GoogleAnalyticsClientService\n>()(\"@useairfoil/producer-google-analytics/GoogleAnalyticsClient\") {}\n\nclass QuotaExhausted extends Data.TaggedError(\"QuotaExhausted\")<{ readonly message: string }> {}\n\nconst statusError = (response: HttpClientResponse.HttpClientResponse, name: string) =>\n HttpClientResponse.schemaBodyJson(ErrorBodySchema)(response).pipe(\n Effect.map((body) => body.error),\n Effect.orElseSucceed(() => undefined),\n Effect.flatMap(\n (error): Effect.Effect<never, ConnectorError | QuotaExhausted> =>\n response.status === 429\n ? Effect.fail(\n new QuotaExhausted({\n message: `Google Analytics quota used up for ${name}${error?.message ? `: ${error.message}` : \"\"}`,\n }),\n )\n : Effect.fail(\n new ConnectorError({\n message: `Google Analytics API returned ${response.status}${error?.status ? ` ${error.status}` : \"\"} for ${name}${error?.message ? `: ${error.message}` : \"\"}`,\n }),\n ),\n ),\n Effect.tapError((error) => Telemetry.annotateError(\"api_status\", error)),\n );\n\nexport const make = Effect.fnUntraced(function* (\n config: Pick<GoogleAnalyticsConfig, \"propertyId\">,\n) {\n const auth = yield* GoogleAnalyticsAuth.GoogleAnalyticsAuth;\n const semaphore = yield* Semaphore.make(maxConcurrent);\n const client = (yield* HttpClient.HttpClient).pipe(\n HttpClient.mapRequest(HttpClientRequest.acceptJson),\n HttpClient.mapRequestEffect((request) =>\n auth.get.pipe(\n Effect.map((token) => HttpClientRequest.bearerToken(request, Redacted.value(token))),\n ),\n ),\n // Short retries cover 5xx and the concurrent request limit. 5xx count\n // against a small hourly server error quota, so keep the cap.\n HttpClient.retryTransient({\n schedule: Metrics.retrySchedule({\n connector: manifest.name,\n baseDelay: retryBaseDelay,\n times: maxRetries,\n }),\n }),\n );\n const url = `${baseUrl}/properties/${config.propertyId}:runReport`;\n\n const runReport = (name: string, request: ReportRequest) =>\n Effect.gen(function* () {\n const response = yield* client\n .execute(\n HttpClientRequest.post(url).pipe(\n HttpClientRequest.bodyJsonUnsafe({\n dateRanges: [{ startDate: request.startDate, endDate: request.endDate }],\n dimensions: request.dimensions.map((dimension) => ({ name: dimension })),\n metrics: request.metrics.map((metric) => ({ name: metric })),\n // Sort by every dimension so offset pages stay stable.\n orderBys: request.dimensions.map((dimension) => ({\n dimension: { dimensionName: dimension },\n })),\n limit: request.limit,\n offset: request.offset,\n }),\n ),\n )\n .pipe(\n Effect.tapError((error) => Telemetry.annotateError(\"api_http\", error)),\n Effect.mapError(\n (error) =>\n new ConnectorError({\n message: `Google Analytics API request failed for ${name}`,\n cause: error,\n }),\n ),\n );\n return yield* HttpClientResponse.matchStatus(response, {\n \"2xx\": (ok) =>\n HttpClientResponse.schemaBodyJson(RunReportResponseSchema)(ok).pipe(\n Effect.tapError((error) => Telemetry.annotateError(\"api_decode\", error)),\n Effect.mapError(\n (error) =>\n new ConnectorError({\n message: `Google Analytics API response for ${name} does not match the schema`,\n cause: error,\n }),\n ),\n ),\n orElse: (failed) => statusError(failed, name),\n });\n }).pipe(\n Effect.timeoutOrElse({\n duration: requestTimeout,\n orElse: () =>\n Effect.fail(\n new ConnectorError({ message: `Google Analytics API request timed out for ${name}` }),\n ),\n }),\n // The timeout starts once a permit is free, so queue time doesn't count.\n semaphore.withPermits(1),\n Effect.tapErrorTag(\"QuotaExhausted\", (error) =>\n Effect.logWarning(`${error.message}. Waiting to try again.`),\n ),\n Effect.retry({\n schedule: limitRetry,\n while: (error) => error._tag === \"QuotaExhausted\",\n }),\n Effect.catchTag(\"QuotaExhausted\", (error) =>\n Effect.fail(new ConnectorError({ message: error.message })),\n ),\n Effect.withSpan(Telemetry.SpanName.apiFetch, {\n kind: \"client\",\n attributes: { [Telemetry.Attr.apiPath]: `runReport:${name}` },\n }),\n );\n\n // Every report carries the zone; one small report reads it. Failures aren't\n // kept, and the hour picks up a changed zone.\n const timeZone = yield* Effect.cachedWithTTL(\n runReport(\"time_zone\", {\n dimensions: [\"date\"],\n metrics: [\"sessions\"],\n startDate: \"yesterday\",\n endDate: \"yesterday\",\n limit: 1,\n offset: 0,\n }).pipe(\n Effect.flatMap((response) =>\n DateTime.zoneMakeNamedEffect(response.metadata.timeZone).pipe(\n Effect.mapError(\n (cause) =>\n new ConnectorError({\n message: `Unknown Google Analytics time zone ${response.metadata.timeZone}`,\n cause,\n }),\n ),\n ),\n ),\n ),\n (exit) => (Exit.isSuccess(exit) ? Duration.hours(1) : Duration.zero),\n );\n\n return GoogleAnalyticsClient.of({ runReport, timeZone });\n});\n\nexport const layer = (\n config: GoogleAnalyticsConfig,\n): Layer.Layer<GoogleAnalyticsClient, never, HttpClient.HttpClient> =>\n Layer.effect(GoogleAnalyticsClient)(make(config)).pipe(\n Layer.provide(GoogleAnalyticsAuth.layer(config)),\n );\n\nexport const layerConfig = (\n config: Config.Wrap<GoogleAnalyticsConfig>,\n): Layer.Layer<GoogleAnalyticsClient, Config.ConfigError, HttpClient.HttpClient> =>\n Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));\n","import { Iceberg } from \"@useairfoil/connector-kit\";\nimport { Schema, SchemaTransformation } from \"effect\";\n\nexport const field = (fieldId: number, description: string) =>\n Iceberg.field(fieldId, { description });\n\n/** The API's `YYYYMMDD` as `YYYY-MM-DD`. */\nexport const isoDay = (date: string) => `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6)}`;\n\nconst ReportDate = Schema.String.check(Schema.isPattern(/^\\d{8}$/)).pipe(\n Schema.decodeTo(\n Schema.String,\n SchemaTransformation.transform({\n decode: isoDay,\n encode: (date) => date.replaceAll(\"-\", \"\"),\n }),\n ),\n);\n\n/** `TYPE_INTEGER` metrics, as an Iceberg long. */\nexport const count = (fieldId: number, description: string) =>\n Schema.BigIntFromString.pipe(field(fieldId, description));\n\n/** Float, currency, and seconds metrics, as an Iceberg double. */\nexport const amount = (fieldId: number, description: string) =>\n Schema.FiniteFromString.pipe(field(fieldId, description));\n\n/** Dimension values are never null. GA uses `(not set)` and `(other)` instead. */\nexport const dimension = (fieldId: number, description: string) =>\n Schema.String.pipe(field(fieldId, description));\n\n/** `version` is the fetch time, since report rows have no update time. */\nexport const common = {\n id: Schema.String.pipe(field(1, \"JSON array of the property ID, date, and dimension values.\")),\n version: Schema.Date.pipe(field(2, \"Time the row was read from Google Analytics.\")),\n property_id: Schema.String.pipe(field(3, \"GA4 property ID.\")),\n date: ReportDate.pipe(field(4, \"Day in the property's time zone, as YYYY-MM-DD.\")),\n};\n\nexport const currencyCode = Schema.String.pipe(\n field(50, \"Currency of the revenue fields, the property's currency.\"),\n);\n","import { ConnectorError } from \"@useairfoil/connector-kit\";\nimport { DateTime, Effect, Option, Schema } from \"effect\";\n\nimport type { GoogleAnalyticsClientService } from \"../../client/client\";\nimport type { RunReportResponse } from \"../../schemas/api\";\nimport type { ReportSpec } from \"./spec\";\n\nimport { isoDay } from \"../../schemas/shared\";\n\n// The API returns at most 250,000 rows a request. Smaller pages keep memory low.\nconst pageSize = 100_000;\n\nexport type ReportPage<Row> = {\n readonly rows: ReadonlyArray<Row>;\n /** Offset of the next page of the same day. */\n readonly next: Option.Option<number>;\n};\n\ntype ReportRow = NonNullable<RunReportResponse[\"rows\"]>[number];\n\n/** `[column, apiName]` pairs, in request order. */\nconst pairs = (columns: Readonly<Record<string, string | undefined>>) =>\n Object.entries(columns).flatMap(([column, name]) =>\n name === undefined ? [] : [[column, name] as const],\n );\n\nconst valuesByName = (\n headers: ReadonlyArray<{ readonly name: string }>,\n values: ReadonlyArray<{ readonly value: string }>,\n) => new Map(headers.map((header, index) => [header.name, values[index]?.value]));\n\n/** Logs the response flags that mean some data is hidden or estimated. */\nconst warnDataLoss = (name: string, day: string, metadata: RunReportResponse[\"metadata\"]) => {\n const flags = {\n otherRow: metadata.dataLossFromOtherRow === true,\n thresholded: metadata.subjectToThresholding === true,\n sampled: (metadata.samplingMetadatas?.length ?? 0) > 0,\n truncated: (metadata.dataTruncationReasons?.length ?? 0) > 0,\n };\n return Object.values(flags).some(Boolean)\n ? Effect.logWarning(\"Google Analytics report is incomplete\").pipe(\n Effect.annotateLogs({ report: name, day, ...flags }),\n )\n : Effect.void;\n};\n\nexport const readDay = <Row extends object>(\n client: GoogleAnalyticsClientService,\n propertyId: string,\n spec: ReportSpec<Row>,\n options: { readonly day: string; readonly offset: number; readonly limit?: number },\n): Effect.Effect<ReportPage<Row>, ConnectorError> =>\n Effect.gen(function* () {\n const dimensions = pairs(spec.dimensions);\n const metrics = pairs(spec.metrics);\n const columns = [...dimensions, ...metrics];\n const response = yield* client.runReport(spec.name, {\n dimensions: [\"date\", ...dimensions.map(([, name]) => name)],\n metrics: metrics.map(([, name]) => name),\n startDate: options.day,\n endDate: options.day,\n limit: options.limit ?? pageSize,\n offset: options.offset,\n });\n yield* warnDataLoss(spec.name, options.day, response.metadata);\n const version = yield* DateTime.nowAsDate;\n const decodeRow = Schema.decodeUnknownEffect(spec.rowSchema);\n\n const toRow = (row: ReportRow) => {\n const values = new Map([\n ...valuesByName(response.dimensionHeaders, row.dimensionValues),\n ...valuesByName(response.metricHeaders, row.metricValues),\n ]);\n const date = values.get(\"date\") ?? \"\";\n return decodeRow({\n id: JSON.stringify([\n propertyId,\n isoDay(date),\n ...dimensions.map(([, name]) => values.get(name)),\n ]),\n version,\n property_id: propertyId,\n date,\n // Tables without revenue leave it out.\n currency_code: response.metadata.currencyCode,\n ...Object.fromEntries(columns.map(([column, name]) => [column, values.get(name)])),\n }).pipe(\n Effect.mapError(\n (cause) =>\n new ConnectorError({\n message: `Unexpected Google Analytics row in ${spec.name} for ${options.day}`,\n cause,\n }),\n ),\n );\n };\n\n const rows = yield* Effect.forEach(response.rows ?? [], toRow);\n const read = options.offset + rows.length;\n return {\n rows,\n next: rows.length > 0 && read < (response.rowCount ?? 0) ? Option.some(read) : Option.none(),\n };\n });\n","import { ConnectorError, Cursor, Fetch } from \"@useairfoil/connector-kit\";\nimport { DateTime, Duration, Effect, Option, Schema } from \"effect\";\n\nimport type { GoogleAnalyticsClientService } from \"../../client/client\";\nimport type { GoogleAnalyticsConfig } from \"../../manifest\";\nimport type { ReportSpec } from \"./spec\";\n\nimport { readDay } from \"./read\";\n\n// Daily data is ready about 12 hours after the day ends, so polling more often\n// mostly reads the same numbers.\nconst changesInterval = Duration.hours(6);\n\ntype ReportConfig = Pick<GoogleAnalyticsConfig, \"propertyId\" | \"startDate\" | \"lookbackDays\">;\n\nconst Day = Schema.String.check(Schema.isPattern(/^\\d{4}-\\d{2}-\\d{2}$/));\n\nconst addDays = (day: string, days: number) =>\n DateTime.formatIsoDateUtc(DateTime.add(DateTime.makeUnsafe(day), { days }));\n\n/** The calendar day of `time` in the property's time zone. */\nconst dayIn = (zone: DateTime.TimeZone, time: DateTime.DateTime) =>\n DateTime.formatIsoDate(DateTime.setZone(time, zone));\n\nconst minDay = (a: string, b: string) => (a < b ? a : b);\n\n/** Reports cover whole days, so backfill ends the day before the cutoff. */\nconst lastBackfillDay = (zone: DateTime.TimeZone, cutoff: DateTime.Utc) =>\n addDays(dayIn(zone, cutoff), -1);\n\n// The API rejects earlier days.\nconst firstDay = \"2015-08-14\";\n\nconst startDay = (config: ReportConfig, end: string) =>\n Option.match(config.startDate, {\n onNone: () =>\n Effect.succeed(\n DateTime.formatIsoDateUtc(DateTime.add(DateTime.makeUnsafe(end), { years: -2 })),\n ),\n onSome: (startDate) =>\n Schema.decodeUnknownEffect(Day)(startDate).pipe(\n Effect.mapError(\n (cause) => new ConnectorError({ message: \"GA_START_DATE must be YYYY-MM-DD\", cause }),\n ),\n ),\n }).pipe(Effect.map((day) => (day < firstDay ? firstDay : day)));\n\nconst cursorError = (cause: Schema.SchemaError) =>\n new ConnectorError({ message: \"Invalid Google Analytics cursor\", cause });\n\nconst PageCursorSchema = Schema.fromJsonString(Schema.Struct({ day: Day, offset: Schema.Int }));\n\n/**\n * `through` is the last day fully read. `pending` is set while a run is split:\n * the day and offset to go on from, and the last day of that run.\n */\nconst ChangesCursorSchema = Schema.Struct({\n through: Day,\n pending: Schema.optional(Schema.Struct({ day: Day, end: Day, offset: Schema.Int })),\n});\n\ntype ChangesCursor = Schema.Schema.Type<typeof ChangesCursorSchema>;\n\n// The first cursor is the backfill cutoff, a plain ISO time.\nconst ChangesCursorCodec = Schema.Union([\n Schema.fromJsonString(ChangesCursorSchema),\n Schema.DateTimeUtcFromString,\n]);\n\nexport const reportCheck = <Row extends object>(\n client: GoogleAnalyticsClientService,\n config: ReportConfig,\n spec: ReportSpec<Row>,\n) =>\n Effect.gen(function* () {\n const zone = yield* client.timeZone;\n const yesterday = addDays(dayIn(zone, yield* DateTime.now), -1);\n yield* startDay(config, yesterday);\n yield* readDay(client, config.propertyId, spec, { day: yesterday, offset: 0, limit: 1 });\n });\n\nexport const reportBackfill = <Row extends object>(\n client: GoogleAnalyticsClientService,\n config: ReportConfig,\n spec: ReportSpec<Row>,\n) =>\n Fetch.page({\n pageCursor: Cursor.string(),\n cutoff: Cursor.isoDateTime(),\n fetch: ({ pageCursor, cutoff }) =>\n Effect.gen(function* () {\n const zone = yield* client.timeZone;\n const cutoffTime = yield* Schema.decodeUnknownEffect(Schema.DateTimeUtcFromString)(\n String(cutoff),\n ).pipe(Effect.mapError(cursorError));\n const end = lastBackfillDay(zone, cutoffTime);\n const at =\n pageCursor === undefined\n ? { day: yield* startDay(config, end), offset: 0 }\n : yield* Schema.decodeUnknownEffect(PageCursorSchema)(String(pageCursor)).pipe(\n Effect.mapError(cursorError),\n );\n if (at.day > end) return { rows: [], hasMore: false };\n\n const page = yield* readDay(client, config.propertyId, spec, at);\n const next = Option.match(page.next, {\n onSome: (offset) => ({ day: at.day, offset }),\n onNone: () => ({ day: addDays(at.day, 1), offset: 0 }),\n });\n if (next.day > end) return { rows: page.rows, hasMore: false };\n return {\n rows: page.rows,\n hasMore: true,\n nextPageCursor: yield* Schema.encodeEffect(PageCursorSchema)(next).pipe(\n Effect.mapError(cursorError),\n ),\n };\n }),\n });\n\n/**\n * Reads the last `lookbackDays` again on every run, since Google keeps\n * updating recent days. After downtime it also reads the days it missed.\n */\nexport const reportChanges = <Row extends object>(\n client: GoogleAnalyticsClientService,\n config: ReportConfig,\n spec: ReportSpec<Row>,\n) =>\n Fetch.changes({\n cursor: Cursor.string(),\n interval: changesInterval,\n fetch: ({ cursor }) =>\n Effect.gen(function* () {\n const zone = yield* client.timeZone;\n const decoded = yield* Schema.decodeUnknownEffect(ChangesCursorCodec)(String(cursor)).pipe(\n Effect.mapError(cursorError),\n );\n const state: ChangesCursor = DateTime.isDateTime(decoded)\n ? { through: lastBackfillDay(zone, decoded) }\n : decoded;\n const yesterday = addDays(dayIn(zone, yield* DateTime.now), -1);\n const window = state.pending ?? {\n day: minDay(addDays(state.through, 1), addDays(yesterday, 1 - config.lookbackDays)),\n end: yesterday,\n offset: 0,\n };\n\n const page = yield* readDay(client, config.propertyId, spec, window);\n const pending = Option.match(page.next, {\n onSome: (offset) => Option.some({ ...window, offset }),\n onNone: () =>\n window.day < window.end\n ? Option.some({ ...window, day: addDays(window.day, 1), offset: 0 })\n : Option.none(),\n });\n const next: ChangesCursor = Option.match(pending, {\n onSome: (pending) => ({ through: state.through, pending }),\n onNone: () => ({ through: window.end }),\n });\n return {\n rows: page.rows,\n cursor: yield* Schema.encodeEffect(Schema.fromJsonString(ChangesCursorSchema))(next).pipe(\n Effect.mapError(cursorError),\n ),\n hasMore: Option.isSome(pending),\n };\n }),\n });\n","import type { ConnectorError, Identified } from \"@useairfoil/connector-kit\";\nimport type { Effect, Schema } from \"effect\";\n\nimport type { GoogleAnalyticsClientService } from \"../../client/client\";\nimport type { GoogleAnalyticsConfig } from \"../../manifest\";\nimport type { ReportSpec } from \"./spec\";\n\nimport { reportBackfill, reportChanges, reportCheck } from \"./sources\";\n\n/** What `Resource.entity` takes for a report. */\nexport type ReportEntity<Row extends object> = {\n readonly name: string;\n readonly rowSchema: Schema.Decoder<Row> & Identified<\"id\">;\n readonly version: \"version\";\n readonly check: Effect.Effect<void, ConnectorError>;\n readonly backfill: ReturnType<typeof reportBackfill<Row>>;\n readonly changes: ReturnType<typeof reportChanges<Row>>;\n};\n\nexport const reportEntity = <Row extends object>(\n client: GoogleAnalyticsClientService,\n config: GoogleAnalyticsConfig,\n spec: ReportSpec<Row>,\n): ReportEntity<Row> => ({\n name: spec.name,\n rowSchema: spec.rowSchema,\n version: \"version\",\n check: reportCheck(client, config, spec),\n backfill: reportBackfill(client, config, spec),\n changes: reportChanges(client, config, spec),\n});\n","import { Iceberg } from \"@useairfoil/connector-kit\";\nimport { Schema } from \"effect\";\n\nimport { amount, common, count, currencyCode, dimension } from \"./shared\";\n\n// User counts are distinct users, so they don't add up across rows or days.\n\nexport const DailyOverviewSchema = Schema.Struct({\n ...common,\n active_users: count(30, \"Users who engaged on the day.\"),\n new_users: count(31, \"Users who came for the first time.\"),\n total_users: count(32, \"Users who logged any event.\"),\n sessions: count(33, \"Sessions that started.\"),\n engaged_sessions: count(\n 34,\n \"Sessions that lasted 10 seconds or more, or had a key event or 2 page views.\",\n ),\n screen_page_views: count(35, \"Page and screen views.\"),\n event_count: count(36, \"Events.\"),\n key_events: amount(37, \"Key events. Can be fractional after attribution.\"),\n total_revenue: amount(38, \"Revenue from purchases, subscriptions, and ads.\"),\n user_engagement_duration: amount(39, \"Seconds the site or app was in the foreground.\"),\n currency_code: currencyCode,\n})\n .annotate({ description: \"Google Analytics totals per day.\" })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type DailyOverview = Schema.Schema.Type<typeof DailyOverviewSchema>;\n\nexport const TrafficSourceSchema = Schema.Struct({\n ...common,\n session_source: dimension(10, \"Source of the session, such as google.\"),\n session_medium: dimension(11, \"Medium of the session, such as organic.\"),\n session_campaign_name: dimension(12, \"Campaign of the session.\"),\n session_default_channel_group: dimension(13, \"Default channel group of the session.\"),\n sessions: count(30, \"Sessions that started.\"),\n engaged_sessions: count(31, \"Engaged sessions.\"),\n active_users: count(32, \"Users who engaged.\"),\n new_users: count(33, \"Users who came for the first time.\"),\n screen_page_views: count(34, \"Page and screen views.\"),\n event_count: count(35, \"Events.\"),\n key_events: amount(36, \"Key events.\"),\n total_revenue: amount(37, \"Revenue from purchases, subscriptions, and ads.\"),\n user_engagement_duration: amount(38, \"Seconds the site or app was in the foreground.\"),\n currency_code: currencyCode,\n})\n .annotate({ description: \"Google Analytics sessions per day and traffic source.\" })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type TrafficSource = Schema.Schema.Type<typeof TrafficSourceSchema>;\n\nexport const UserAcquisitionSchema = Schema.Struct({\n ...common,\n first_user_source: dimension(10, \"Source that first brought the user.\"),\n first_user_medium: dimension(11, \"Medium that first brought the user.\"),\n first_user_campaign_name: dimension(12, \"Campaign that first brought the user.\"),\n first_user_default_channel_group: dimension(\n 13,\n \"Default channel group that first brought the user.\",\n ),\n new_users: count(30, \"Users who came for the first time.\"),\n active_users: count(31, \"Users who engaged.\"),\n sessions: count(32, \"Sessions that started.\"),\n engaged_sessions: count(33, \"Engaged sessions.\"),\n key_events: amount(34, \"Key events.\"),\n total_revenue: amount(35, \"Revenue from purchases, subscriptions, and ads.\"),\n currency_code: currencyCode,\n})\n .annotate({ description: \"Google Analytics users per day and first traffic source.\" })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type UserAcquisition = Schema.Schema.Type<typeof UserAcquisitionSchema>;\n\nexport const PageSchema = Schema.Struct({\n ...common,\n host_name: dimension(10, \"Host name, such as www.example.com.\"),\n page_path: dimension(11, \"Page path, without the query string.\"),\n page_title: dimension(12, \"Page title.\"),\n screen_page_views: count(30, \"Page and screen views.\"),\n active_users: count(31, \"Users who engaged.\"),\n event_count: count(32, \"Events.\"),\n key_events: amount(33, \"Key events.\"),\n user_engagement_duration: amount(34, \"Seconds the page was in the foreground.\"),\n})\n .annotate({ description: \"Google Analytics page views per day and page.\" })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type Page = Schema.Schema.Type<typeof PageSchema>;\n\nexport const LandingPageSchema = Schema.Struct({\n ...common,\n landing_page: dimension(10, \"First page path of the session, without the query string.\"),\n sessions: count(30, \"Sessions that started.\"),\n engaged_sessions: count(31, \"Engaged sessions.\"),\n active_users: count(32, \"Users who engaged.\"),\n new_users: count(33, \"Users who came for the first time.\"),\n key_events: amount(34, \"Key events.\"),\n total_revenue: amount(35, \"Revenue from purchases, subscriptions, and ads.\"),\n currency_code: currencyCode,\n})\n .annotate({ description: \"Google Analytics sessions per day and landing page.\" })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type LandingPage = Schema.Schema.Type<typeof LandingPageSchema>;\n\nexport const EventSchema = Schema.Struct({\n ...common,\n event_name: dimension(10, \"Event name, such as page_view.\"),\n event_count: count(30, \"Events.\"),\n total_users: count(31, \"Users who logged the event.\"),\n event_value: amount(32, \"Sum of the event's value parameter.\"),\n key_events: amount(33, \"Key events.\"),\n})\n .annotate({ description: \"Google Analytics events per day and event name.\" })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type Event = Schema.Schema.Type<typeof EventSchema>;\n\nexport const LocationSchema = Schema.Struct({\n ...common,\n country: dimension(10, \"Country of the user.\"),\n region: dimension(11, \"Region of the user.\"),\n city: dimension(12, \"City of the user.\"),\n active_users: count(30, \"Users who engaged.\"),\n new_users: count(31, \"Users who came for the first time.\"),\n sessions: count(32, \"Sessions that started.\"),\n engaged_sessions: count(33, \"Engaged sessions.\"),\n key_events: amount(34, \"Key events.\"),\n total_revenue: amount(35, \"Revenue from purchases, subscriptions, and ads.\"),\n currency_code: currencyCode,\n})\n .annotate({ description: \"Google Analytics users per day and location.\" })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type Location = Schema.Schema.Type<typeof LocationSchema>;\n\nexport const DeviceSchema = Schema.Struct({\n ...common,\n device_category: dimension(10, \"Device category: desktop, mobile, or tablet.\"),\n platform: dimension(11, \"Platform: web, iOS, or Android.\"),\n operating_system: dimension(12, \"Operating system.\"),\n browser: dimension(13, \"Browser.\"),\n active_users: count(30, \"Users who engaged.\"),\n new_users: count(31, \"Users who came for the first time.\"),\n sessions: count(32, \"Sessions that started.\"),\n engaged_sessions: count(33, \"Engaged sessions.\"),\n screen_page_views: count(34, \"Page and screen views.\"),\n key_events: amount(35, \"Key events.\"),\n})\n .annotate({ description: \"Google Analytics users per day and device.\" })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type Device = Schema.Schema.Type<typeof DeviceSchema>;\n\nexport const EcommerceItemSchema = Schema.Struct({\n ...common,\n item_id: dimension(10, \"Item ID.\"),\n item_name: dimension(11, \"Item name.\"),\n item_brand: dimension(12, \"Item brand.\"),\n item_category: dimension(13, \"First item category.\"),\n items_viewed: count(30, \"Units viewed.\"),\n items_added_to_cart: count(31, \"Units added to carts.\"),\n items_purchased: count(32, \"Units purchased.\"),\n item_revenue: amount(33, \"Revenue from the item, without tax and shipping.\"),\n currency_code: currencyCode,\n})\n .annotate({\n description: \"Google Analytics ecommerce items per day. Empty without ecommerce events.\",\n })\n .pipe(Iceberg.identifier(\"id\"));\n\nexport type EcommerceItem = Schema.Schema.Type<typeof EcommerceItemSchema>;\n","import type { ReportSpec } from \"./spec\";\n\nimport {\n type DailyOverview,\n DailyOverviewSchema,\n type Device,\n DeviceSchema,\n type EcommerceItem,\n EcommerceItemSchema,\n type Event,\n EventSchema,\n type LandingPage,\n LandingPageSchema,\n type Location,\n LocationSchema,\n type Page,\n PageSchema,\n type TrafficSource,\n TrafficSourceSchema,\n type UserAcquisition,\n UserAcquisitionSchema,\n} from \"../../schemas/reports\";\n\n// Every pair here is one Google shows together in its own reports. Check new\n// ones with `checkCompatibility` first.\n\nexport const dailyOverview: ReportSpec<DailyOverview> = {\n name: \"daily_overview\",\n dimensions: {},\n metrics: {\n active_users: \"activeUsers\",\n new_users: \"newUsers\",\n total_users: \"totalUsers\",\n sessions: \"sessions\",\n engaged_sessions: \"engagedSessions\",\n screen_page_views: \"screenPageViews\",\n event_count: \"eventCount\",\n key_events: \"keyEvents\",\n total_revenue: \"totalRevenue\",\n user_engagement_duration: \"userEngagementDuration\",\n },\n rowSchema: DailyOverviewSchema,\n};\n\nexport const trafficSources: ReportSpec<TrafficSource> = {\n name: \"traffic_sources\",\n dimensions: {\n session_source: \"sessionSource\",\n session_medium: \"sessionMedium\",\n session_campaign_name: \"sessionCampaignName\",\n session_default_channel_group: \"sessionDefaultChannelGroup\",\n },\n metrics: {\n sessions: \"sessions\",\n engaged_sessions: \"engagedSessions\",\n active_users: \"activeUsers\",\n new_users: \"newUsers\",\n screen_page_views: \"screenPageViews\",\n event_count: \"eventCount\",\n key_events: \"keyEvents\",\n total_revenue: \"totalRevenue\",\n user_engagement_duration: \"userEngagementDuration\",\n },\n rowSchema: TrafficSourceSchema,\n};\n\nexport const userAcquisition: ReportSpec<UserAcquisition> = {\n name: \"user_acquisition\",\n dimensions: {\n first_user_source: \"firstUserSource\",\n first_user_medium: \"firstUserMedium\",\n first_user_campaign_name: \"firstUserCampaignName\",\n first_user_default_channel_group: \"firstUserDefaultChannelGroup\",\n },\n metrics: {\n new_users: \"newUsers\",\n active_users: \"activeUsers\",\n sessions: \"sessions\",\n engaged_sessions: \"engagedSessions\",\n key_events: \"keyEvents\",\n total_revenue: \"totalRevenue\",\n },\n rowSchema: UserAcquisitionSchema,\n};\n\nexport const pages: ReportSpec<Page> = {\n name: \"pages\",\n dimensions: { host_name: \"hostName\", page_path: \"pagePath\", page_title: \"pageTitle\" },\n metrics: {\n screen_page_views: \"screenPageViews\",\n active_users: \"activeUsers\",\n event_count: \"eventCount\",\n key_events: \"keyEvents\",\n user_engagement_duration: \"userEngagementDuration\",\n },\n rowSchema: PageSchema,\n};\n\nexport const landingPages: ReportSpec<LandingPage> = {\n name: \"landing_pages\",\n dimensions: { landing_page: \"landingPage\" },\n metrics: {\n sessions: \"sessions\",\n engaged_sessions: \"engagedSessions\",\n active_users: \"activeUsers\",\n new_users: \"newUsers\",\n key_events: \"keyEvents\",\n total_revenue: \"totalRevenue\",\n },\n rowSchema: LandingPageSchema,\n};\n\nexport const events: ReportSpec<Event> = {\n name: \"events\",\n dimensions: { event_name: \"eventName\" },\n metrics: {\n event_count: \"eventCount\",\n total_users: \"totalUsers\",\n event_value: \"eventValue\",\n key_events: \"keyEvents\",\n },\n rowSchema: EventSchema,\n};\n\nexport const locations: ReportSpec<Location> = {\n name: \"locations\",\n dimensions: { country: \"country\", region: \"region\", city: \"city\" },\n metrics: {\n active_users: \"activeUsers\",\n new_users: \"newUsers\",\n sessions: \"sessions\",\n engaged_sessions: \"engagedSessions\",\n key_events: \"keyEvents\",\n total_revenue: \"totalRevenue\",\n },\n rowSchema: LocationSchema,\n};\n\nexport const devices: ReportSpec<Device> = {\n name: \"devices\",\n dimensions: {\n device_category: \"deviceCategory\",\n platform: \"platform\",\n operating_system: \"operatingSystem\",\n browser: \"browser\",\n },\n metrics: {\n active_users: \"activeUsers\",\n new_users: \"newUsers\",\n sessions: \"sessions\",\n engaged_sessions: \"engagedSessions\",\n screen_page_views: \"screenPageViews\",\n key_events: \"keyEvents\",\n },\n rowSchema: DeviceSchema,\n};\n\nexport const ecommerceItems: ReportSpec<EcommerceItem> = {\n name: \"ecommerce_items\",\n dimensions: {\n item_id: \"itemId\",\n item_name: \"itemName\",\n item_brand: \"itemBrand\",\n item_category: \"itemCategory\",\n },\n metrics: {\n items_viewed: \"itemsViewed\",\n items_added_to_cart: \"itemsAddedToCart\",\n items_purchased: \"itemsPurchased\",\n item_revenue: \"itemRevenue\",\n },\n rowSchema: EcommerceItemSchema,\n};\n","import { Connector, Resource } from \"@useairfoil/connector-kit\";\nimport { Config, Context, Effect, Layer } from \"effect\";\n\nimport type { GoogleAnalyticsConfig } from \"./manifest\";\n\nimport * as GoogleAnalyticsClient from \"./client/client\";\nimport { reportEntity } from \"./resources/reports/resource\";\nimport {\n dailyOverview,\n devices,\n ecommerceItems,\n events,\n landingPages,\n locations,\n pages,\n trafficSources,\n userAcquisition,\n} from \"./resources/reports/specs\";\nexport { GoogleAnalyticsConfigDef, manifest } from \"./manifest\";\nexport type { GoogleAnalyticsConfig } from \"./manifest\";\n\nexport const make = Effect.fnUntraced(function* (config: GoogleAnalyticsConfig) {\n const client = yield* GoogleAnalyticsClient.GoogleAnalyticsClient;\n\n return Connector.define({\n name: \"producer-google-analytics\",\n title: \"Google Analytics\",\n resources: [\n Resource.entity(reportEntity(client, config, dailyOverview)),\n Resource.entity(reportEntity(client, config, trafficSources)),\n Resource.entity(reportEntity(client, config, userAcquisition)),\n Resource.entity(reportEntity(client, config, pages)),\n Resource.entity(reportEntity(client, config, landingPages)),\n Resource.entity(reportEntity(client, config, events)),\n Resource.entity(reportEntity(client, config, locations)),\n Resource.entity(reportEntity(client, config, devices)),\n Resource.entity(reportEntity(client, config, ecommerceItems)),\n ],\n });\n});\n\nexport type GoogleAnalyticsConnectorRuntime = Effect.Success<ReturnType<typeof make>>;\n\nexport class GoogleAnalyticsConnector extends Context.Service<\n GoogleAnalyticsConnector,\n GoogleAnalyticsConnectorRuntime\n>()(\"@useairfoil/producer-google-analytics/GoogleAnalyticsConnector\") {}\n\nexport const layer = (config: GoogleAnalyticsConfig) =>\n Layer.effect(GoogleAnalyticsConnector)(\n make(config).pipe(Effect.annotateLogs({ component: \"google-analytics\" })),\n ).pipe(Layer.provide(GoogleAnalyticsClient.layer(config)));\n\nexport const layerConfig = (config: Config.Wrap<GoogleAnalyticsConfig>) =>\n Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@useairfoil/producer-google-analytics",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"repository": "useairfoil/connector-kit",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"src",
|
|
8
|
+
"README.md"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.mjs",
|
|
12
|
+
"types": "./dist/index.d.mts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.mts",
|
|
16
|
+
"import": "./dist/index.mjs",
|
|
17
|
+
"default": "./dist/index.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./manifest": {
|
|
20
|
+
"types": "./dist/manifest.d.mts",
|
|
21
|
+
"import": "./dist/manifest.mjs",
|
|
22
|
+
"default": "./dist/manifest.mjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsdown",
|
|
27
|
+
"docker:build": "docker build --load --file Dockerfile --tag airfoil/producer-google-analytics:local ../..",
|
|
28
|
+
"start": "tsx --env-file=.env src/main.ts start",
|
|
29
|
+
"sandbox": "tsx --env-file=.env src/main.ts sandbox",
|
|
30
|
+
"demo": "tsx src/main.ts demo",
|
|
31
|
+
"test": "dotenvx run --ignore=MISSING_ENV_FILE --quiet -- vitest",
|
|
32
|
+
"test:ci": "dotenvx run --ignore=MISSING_ENV_FILE --quiet -- vitest run",
|
|
33
|
+
"typecheck": "tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@effect/platform-node": "^4.0.7",
|
|
37
|
+
"@effect/sql-pg": "^4.0.7",
|
|
38
|
+
"@useairfoil/connector-kit": "0.6.1",
|
|
39
|
+
"effect": "^4.0.7"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@dotenvx/dotenvx": "^1.62.0",
|
|
43
|
+
"@effect/vitest": "^4.0.7",
|
|
44
|
+
"@types/node": "^24.2.0",
|
|
45
|
+
"@useairfoil/effect-vcr": "0.4.8",
|
|
46
|
+
"tsdown": "^0.23.0",
|
|
47
|
+
"tsx": "^4.21.0",
|
|
48
|
+
"vitest": "^5.0.3"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Auth, ConnectorError, Telemetry } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Clock, Context, Duration, Effect, Layer, Redacted, Schedule, Schema } from "effect";
|
|
3
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
import type { GoogleAnalyticsConfig } from "../manifest";
|
|
7
|
+
|
|
8
|
+
type GoogleAnalyticsAuthConfig = Pick<GoogleAnalyticsConfig, "clientEmail" | "privateKey">;
|
|
9
|
+
|
|
10
|
+
export type GoogleAnalyticsAuthService = Auth.TokenCache<ConnectorError>;
|
|
11
|
+
|
|
12
|
+
export class GoogleAnalyticsAuth extends Context.Service<
|
|
13
|
+
GoogleAnalyticsAuth,
|
|
14
|
+
GoogleAnalyticsAuthService
|
|
15
|
+
>()("@useairfoil/producer-google-analytics/GoogleAnalyticsAuth") {}
|
|
16
|
+
|
|
17
|
+
export const tokenUrl = "https://oauth2.googleapis.com/token";
|
|
18
|
+
export const scope = "https://www.googleapis.com/auth/analytics.readonly";
|
|
19
|
+
|
|
20
|
+
const TokenResponseSchema = Schema.Struct({
|
|
21
|
+
access_token: Schema.String,
|
|
22
|
+
expires_in: Schema.Number,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const TokenErrorSchema = Schema.Struct({
|
|
26
|
+
error: Schema.optional(Schema.String),
|
|
27
|
+
error_description: Schema.optional(Schema.String),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const base64Url = (value: string | Buffer): string => Buffer.from(value).toString("base64url");
|
|
31
|
+
|
|
32
|
+
/** Signs the service account JWT with RS256. Google allows at most 1 hour. */
|
|
33
|
+
export const makeAssertion = (options: {
|
|
34
|
+
readonly clientEmail: string;
|
|
35
|
+
readonly privateKey: string;
|
|
36
|
+
readonly nowMillis: number;
|
|
37
|
+
}): Effect.Effect<string, ConnectorError> =>
|
|
38
|
+
Effect.try({
|
|
39
|
+
try: () => {
|
|
40
|
+
const now = Math.floor(options.nowMillis / 1000);
|
|
41
|
+
const header = base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
42
|
+
const claims = base64Url(
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
iss: options.clientEmail,
|
|
45
|
+
scope,
|
|
46
|
+
aud: tokenUrl,
|
|
47
|
+
iat: now,
|
|
48
|
+
exp: now + 3600,
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
const signingInput = `${header}.${claims}`;
|
|
52
|
+
// Env files often keep the PEM on one line with escaped newlines.
|
|
53
|
+
const key = createPrivateKey(options.privateKey.replace(/\\n/g, "\n"));
|
|
54
|
+
return `${signingInput}.${base64Url(sign("sha256", Buffer.from(signingInput), key))}`;
|
|
55
|
+
},
|
|
56
|
+
catch: (cause) =>
|
|
57
|
+
new ConnectorError({ message: "Invalid Google service account private key", cause }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const tokenError = (response: HttpClientResponse.HttpClientResponse) =>
|
|
61
|
+
HttpClientResponse.schemaBodyJson(TokenErrorSchema)(response).pipe(
|
|
62
|
+
Effect.map((body) => body.error),
|
|
63
|
+
Effect.orElseSucceed(() => undefined),
|
|
64
|
+
Effect.flatMap((error) =>
|
|
65
|
+
Effect.fail(
|
|
66
|
+
new ConnectorError({
|
|
67
|
+
message:
|
|
68
|
+
error === "invalid_grant" || error === "invalid_client"
|
|
69
|
+
? "Google rejected the service account key"
|
|
70
|
+
: `Google returned ${response.status} for the OAuth token`,
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
export const make = Effect.fnUntraced(function* (config: GoogleAnalyticsAuthConfig) {
|
|
77
|
+
const client = (yield* HttpClient.HttpClient).pipe(
|
|
78
|
+
HttpClient.retryTransient({
|
|
79
|
+
schedule: Schedule.exponential("250 millis").pipe(Schedule.jittered),
|
|
80
|
+
times: 3,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return yield* Auth.makeTokenCache({
|
|
85
|
+
acquire: Effect.gen(function* () {
|
|
86
|
+
const assertion = yield* makeAssertion({
|
|
87
|
+
clientEmail: config.clientEmail,
|
|
88
|
+
privateKey: Redacted.value(config.privateKey),
|
|
89
|
+
nowMillis: yield* Clock.currentTimeMillis,
|
|
90
|
+
});
|
|
91
|
+
const response = yield* client
|
|
92
|
+
.execute(
|
|
93
|
+
HttpClientRequest.post(tokenUrl).pipe(
|
|
94
|
+
HttpClientRequest.acceptJson,
|
|
95
|
+
HttpClientRequest.bodyUrlParams({
|
|
96
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
97
|
+
assertion,
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
.pipe(
|
|
102
|
+
Effect.mapError(
|
|
103
|
+
(cause) => new ConnectorError({ message: "Google OAuth token request failed", cause }),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
const body = yield* HttpClientResponse.matchStatus(response, {
|
|
107
|
+
"2xx": (ok) =>
|
|
108
|
+
HttpClientResponse.schemaBodyJson(TokenResponseSchema)(ok).pipe(
|
|
109
|
+
Effect.mapError(
|
|
110
|
+
(cause) =>
|
|
111
|
+
new ConnectorError({ message: "Invalid Google OAuth token response", cause }),
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
orElse: tokenError,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
value: Redacted.make(body.access_token),
|
|
118
|
+
expiresIn: Duration.seconds(body.expires_in),
|
|
119
|
+
};
|
|
120
|
+
}).pipe(
|
|
121
|
+
Effect.tapError((error) => Telemetry.annotateError("api_auth", error)),
|
|
122
|
+
Effect.withSpan(Telemetry.SpanName.apiFetch, {
|
|
123
|
+
kind: "client",
|
|
124
|
+
attributes: { [Telemetry.Attr.apiPath]: "/token" },
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const layer = (
|
|
131
|
+
config: GoogleAnalyticsAuthConfig,
|
|
132
|
+
): Layer.Layer<GoogleAnalyticsAuth, never, HttpClient.HttpClient> =>
|
|
133
|
+
Layer.effect(GoogleAnalyticsAuth)(make(config));
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { ConnectorError, Metrics, Telemetry } from "@useairfoil/connector-kit";
|
|
2
|
+
import {
|
|
3
|
+
Config,
|
|
4
|
+
Context,
|
|
5
|
+
Data,
|
|
6
|
+
DateTime,
|
|
7
|
+
Duration,
|
|
8
|
+
Effect,
|
|
9
|
+
Exit,
|
|
10
|
+
Layer,
|
|
11
|
+
Redacted,
|
|
12
|
+
Schedule,
|
|
13
|
+
Semaphore,
|
|
14
|
+
} from "effect";
|
|
15
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
16
|
+
|
|
17
|
+
import { type GoogleAnalyticsConfig, manifest } from "../manifest";
|
|
18
|
+
import { ErrorBodySchema, type RunReportResponse, RunReportResponseSchema } from "../schemas/api";
|
|
19
|
+
import * as GoogleAnalyticsAuth from "./auth";
|
|
20
|
+
|
|
21
|
+
export const DATA_API_VERSION = "v1beta";
|
|
22
|
+
|
|
23
|
+
const baseUrl = `https://analyticsdata.googleapis.com/${DATA_API_VERSION}`;
|
|
24
|
+
const maxRetries = 5;
|
|
25
|
+
const retryBaseDelay = Duration.seconds(1);
|
|
26
|
+
const requestTimeout = Duration.minutes(2);
|
|
27
|
+
// Token quotas refill within the hour, or at midnight Pacific for the daily one.
|
|
28
|
+
// Keep waiting; a failed source stays stopped until restart.
|
|
29
|
+
const limitRetry = Schedule.min([Schedule.exponential("1 minute"), Schedule.spaced("10 minutes")]);
|
|
30
|
+
// A standard property allows 10 requests at once. Leave room for other tools.
|
|
31
|
+
const maxConcurrent = 5;
|
|
32
|
+
|
|
33
|
+
export type ReportRequest = {
|
|
34
|
+
readonly dimensions: ReadonlyArray<string>;
|
|
35
|
+
readonly metrics: ReadonlyArray<string>;
|
|
36
|
+
/** `YYYY-MM-DD` in the property's time zone. */
|
|
37
|
+
readonly startDate: string;
|
|
38
|
+
readonly endDate: string;
|
|
39
|
+
readonly limit: number;
|
|
40
|
+
readonly offset: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type GoogleAnalyticsClientService = {
|
|
44
|
+
/** `name` labels the span and errors, such as the resource name. */
|
|
45
|
+
readonly runReport: (
|
|
46
|
+
name: string,
|
|
47
|
+
request: ReportRequest,
|
|
48
|
+
) => Effect.Effect<RunReportResponse, ConnectorError>;
|
|
49
|
+
/** The property's time zone. Report dates are days in this zone. */
|
|
50
|
+
readonly timeZone: Effect.Effect<DateTime.TimeZone, ConnectorError>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export class GoogleAnalyticsClient extends Context.Service<
|
|
54
|
+
GoogleAnalyticsClient,
|
|
55
|
+
GoogleAnalyticsClientService
|
|
56
|
+
>()("@useairfoil/producer-google-analytics/GoogleAnalyticsClient") {}
|
|
57
|
+
|
|
58
|
+
class QuotaExhausted extends Data.TaggedError("QuotaExhausted")<{ readonly message: string }> {}
|
|
59
|
+
|
|
60
|
+
const statusError = (response: HttpClientResponse.HttpClientResponse, name: string) =>
|
|
61
|
+
HttpClientResponse.schemaBodyJson(ErrorBodySchema)(response).pipe(
|
|
62
|
+
Effect.map((body) => body.error),
|
|
63
|
+
Effect.orElseSucceed(() => undefined),
|
|
64
|
+
Effect.flatMap(
|
|
65
|
+
(error): Effect.Effect<never, ConnectorError | QuotaExhausted> =>
|
|
66
|
+
response.status === 429
|
|
67
|
+
? Effect.fail(
|
|
68
|
+
new QuotaExhausted({
|
|
69
|
+
message: `Google Analytics quota used up for ${name}${error?.message ? `: ${error.message}` : ""}`,
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
: Effect.fail(
|
|
73
|
+
new ConnectorError({
|
|
74
|
+
message: `Google Analytics API returned ${response.status}${error?.status ? ` ${error.status}` : ""} for ${name}${error?.message ? `: ${error.message}` : ""}`,
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
Effect.tapError((error) => Telemetry.annotateError("api_status", error)),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export const make = Effect.fnUntraced(function* (
|
|
82
|
+
config: Pick<GoogleAnalyticsConfig, "propertyId">,
|
|
83
|
+
) {
|
|
84
|
+
const auth = yield* GoogleAnalyticsAuth.GoogleAnalyticsAuth;
|
|
85
|
+
const semaphore = yield* Semaphore.make(maxConcurrent);
|
|
86
|
+
const client = (yield* HttpClient.HttpClient).pipe(
|
|
87
|
+
HttpClient.mapRequest(HttpClientRequest.acceptJson),
|
|
88
|
+
HttpClient.mapRequestEffect((request) =>
|
|
89
|
+
auth.get.pipe(
|
|
90
|
+
Effect.map((token) => HttpClientRequest.bearerToken(request, Redacted.value(token))),
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
// Short retries cover 5xx and the concurrent request limit. 5xx count
|
|
94
|
+
// against a small hourly server error quota, so keep the cap.
|
|
95
|
+
HttpClient.retryTransient({
|
|
96
|
+
schedule: Metrics.retrySchedule({
|
|
97
|
+
connector: manifest.name,
|
|
98
|
+
baseDelay: retryBaseDelay,
|
|
99
|
+
times: maxRetries,
|
|
100
|
+
}),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
const url = `${baseUrl}/properties/${config.propertyId}:runReport`;
|
|
104
|
+
|
|
105
|
+
const runReport = (name: string, request: ReportRequest) =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
const response = yield* client
|
|
108
|
+
.execute(
|
|
109
|
+
HttpClientRequest.post(url).pipe(
|
|
110
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
111
|
+
dateRanges: [{ startDate: request.startDate, endDate: request.endDate }],
|
|
112
|
+
dimensions: request.dimensions.map((dimension) => ({ name: dimension })),
|
|
113
|
+
metrics: request.metrics.map((metric) => ({ name: metric })),
|
|
114
|
+
// Sort by every dimension so offset pages stay stable.
|
|
115
|
+
orderBys: request.dimensions.map((dimension) => ({
|
|
116
|
+
dimension: { dimensionName: dimension },
|
|
117
|
+
})),
|
|
118
|
+
limit: request.limit,
|
|
119
|
+
offset: request.offset,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
.pipe(
|
|
124
|
+
Effect.tapError((error) => Telemetry.annotateError("api_http", error)),
|
|
125
|
+
Effect.mapError(
|
|
126
|
+
(error) =>
|
|
127
|
+
new ConnectorError({
|
|
128
|
+
message: `Google Analytics API request failed for ${name}`,
|
|
129
|
+
cause: error,
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
return yield* HttpClientResponse.matchStatus(response, {
|
|
134
|
+
"2xx": (ok) =>
|
|
135
|
+
HttpClientResponse.schemaBodyJson(RunReportResponseSchema)(ok).pipe(
|
|
136
|
+
Effect.tapError((error) => Telemetry.annotateError("api_decode", error)),
|
|
137
|
+
Effect.mapError(
|
|
138
|
+
(error) =>
|
|
139
|
+
new ConnectorError({
|
|
140
|
+
message: `Google Analytics API response for ${name} does not match the schema`,
|
|
141
|
+
cause: error,
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
orElse: (failed) => statusError(failed, name),
|
|
146
|
+
});
|
|
147
|
+
}).pipe(
|
|
148
|
+
Effect.timeoutOrElse({
|
|
149
|
+
duration: requestTimeout,
|
|
150
|
+
orElse: () =>
|
|
151
|
+
Effect.fail(
|
|
152
|
+
new ConnectorError({ message: `Google Analytics API request timed out for ${name}` }),
|
|
153
|
+
),
|
|
154
|
+
}),
|
|
155
|
+
// The timeout starts once a permit is free, so queue time doesn't count.
|
|
156
|
+
semaphore.withPermits(1),
|
|
157
|
+
Effect.tapErrorTag("QuotaExhausted", (error) =>
|
|
158
|
+
Effect.logWarning(`${error.message}. Waiting to try again.`),
|
|
159
|
+
),
|
|
160
|
+
Effect.retry({
|
|
161
|
+
schedule: limitRetry,
|
|
162
|
+
while: (error) => error._tag === "QuotaExhausted",
|
|
163
|
+
}),
|
|
164
|
+
Effect.catchTag("QuotaExhausted", (error) =>
|
|
165
|
+
Effect.fail(new ConnectorError({ message: error.message })),
|
|
166
|
+
),
|
|
167
|
+
Effect.withSpan(Telemetry.SpanName.apiFetch, {
|
|
168
|
+
kind: "client",
|
|
169
|
+
attributes: { [Telemetry.Attr.apiPath]: `runReport:${name}` },
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Every report carries the zone; one small report reads it. Failures aren't
|
|
174
|
+
// kept, and the hour picks up a changed zone.
|
|
175
|
+
const timeZone = yield* Effect.cachedWithTTL(
|
|
176
|
+
runReport("time_zone", {
|
|
177
|
+
dimensions: ["date"],
|
|
178
|
+
metrics: ["sessions"],
|
|
179
|
+
startDate: "yesterday",
|
|
180
|
+
endDate: "yesterday",
|
|
181
|
+
limit: 1,
|
|
182
|
+
offset: 0,
|
|
183
|
+
}).pipe(
|
|
184
|
+
Effect.flatMap((response) =>
|
|
185
|
+
DateTime.zoneMakeNamedEffect(response.metadata.timeZone).pipe(
|
|
186
|
+
Effect.mapError(
|
|
187
|
+
(cause) =>
|
|
188
|
+
new ConnectorError({
|
|
189
|
+
message: `Unknown Google Analytics time zone ${response.metadata.timeZone}`,
|
|
190
|
+
cause,
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
(exit) => (Exit.isSuccess(exit) ? Duration.hours(1) : Duration.zero),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return GoogleAnalyticsClient.of({ runReport, timeZone });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
export const layer = (
|
|
203
|
+
config: GoogleAnalyticsConfig,
|
|
204
|
+
): Layer.Layer<GoogleAnalyticsClient, never, HttpClient.HttpClient> =>
|
|
205
|
+
Layer.effect(GoogleAnalyticsClient)(make(config)).pipe(
|
|
206
|
+
Layer.provide(GoogleAnalyticsAuth.layer(config)),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
export const layerConfig = (
|
|
210
|
+
config: Config.Wrap<GoogleAnalyticsConfig>,
|
|
211
|
+
): Layer.Layer<GoogleAnalyticsClient, Config.ConfigError, HttpClient.HttpClient> =>
|
|
212
|
+
Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));
|
package/src/connector.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Connector, Resource } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Config, Context, Effect, Layer } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { GoogleAnalyticsConfig } from "./manifest";
|
|
5
|
+
|
|
6
|
+
import * as GoogleAnalyticsClient from "./client/client";
|
|
7
|
+
import { reportEntity } from "./resources/reports/resource";
|
|
8
|
+
import {
|
|
9
|
+
dailyOverview,
|
|
10
|
+
devices,
|
|
11
|
+
ecommerceItems,
|
|
12
|
+
events,
|
|
13
|
+
landingPages,
|
|
14
|
+
locations,
|
|
15
|
+
pages,
|
|
16
|
+
trafficSources,
|
|
17
|
+
userAcquisition,
|
|
18
|
+
} from "./resources/reports/specs";
|
|
19
|
+
export { GoogleAnalyticsConfigDef, manifest } from "./manifest";
|
|
20
|
+
export type { GoogleAnalyticsConfig } from "./manifest";
|
|
21
|
+
|
|
22
|
+
export const make = Effect.fnUntraced(function* (config: GoogleAnalyticsConfig) {
|
|
23
|
+
const client = yield* GoogleAnalyticsClient.GoogleAnalyticsClient;
|
|
24
|
+
|
|
25
|
+
return Connector.define({
|
|
26
|
+
name: "producer-google-analytics",
|
|
27
|
+
title: "Google Analytics",
|
|
28
|
+
resources: [
|
|
29
|
+
Resource.entity(reportEntity(client, config, dailyOverview)),
|
|
30
|
+
Resource.entity(reportEntity(client, config, trafficSources)),
|
|
31
|
+
Resource.entity(reportEntity(client, config, userAcquisition)),
|
|
32
|
+
Resource.entity(reportEntity(client, config, pages)),
|
|
33
|
+
Resource.entity(reportEntity(client, config, landingPages)),
|
|
34
|
+
Resource.entity(reportEntity(client, config, events)),
|
|
35
|
+
Resource.entity(reportEntity(client, config, locations)),
|
|
36
|
+
Resource.entity(reportEntity(client, config, devices)),
|
|
37
|
+
Resource.entity(reportEntity(client, config, ecommerceItems)),
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export type GoogleAnalyticsConnectorRuntime = Effect.Success<ReturnType<typeof make>>;
|
|
43
|
+
|
|
44
|
+
export class GoogleAnalyticsConnector extends Context.Service<
|
|
45
|
+
GoogleAnalyticsConnector,
|
|
46
|
+
GoogleAnalyticsConnectorRuntime
|
|
47
|
+
>()("@useairfoil/producer-google-analytics/GoogleAnalyticsConnector") {}
|
|
48
|
+
|
|
49
|
+
export const layer = (config: GoogleAnalyticsConfig) =>
|
|
50
|
+
Layer.effect(GoogleAnalyticsConnector)(
|
|
51
|
+
make(config).pipe(Effect.annotateLogs({ component: "google-analytics" })),
|
|
52
|
+
).pipe(Layer.provide(GoogleAnalyticsClient.layer(config)));
|
|
53
|
+
|
|
54
|
+
export const layerConfig = (config: Config.Wrap<GoogleAnalyticsConfig>) =>
|
|
55
|
+
Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ConnectorApp, Demo, RuntimeConfig } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Command } from "effect/cli";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DailyOverviewSchema,
|
|
6
|
+
DeviceSchema,
|
|
7
|
+
EcommerceItemSchema,
|
|
8
|
+
EventSchema,
|
|
9
|
+
LandingPageSchema,
|
|
10
|
+
LocationSchema,
|
|
11
|
+
PageSchema,
|
|
12
|
+
TrafficSourceSchema,
|
|
13
|
+
UserAcquisitionSchema,
|
|
14
|
+
} from "../schemas/reports";
|
|
15
|
+
import { dayRows } from "./reports";
|
|
16
|
+
|
|
17
|
+
export const demo = Demo.define({
|
|
18
|
+
name: "producer-google-analytics",
|
|
19
|
+
setup: { tables: [], rows: () => ({}) },
|
|
20
|
+
group: {
|
|
21
|
+
tables: [
|
|
22
|
+
Demo.table({ name: "daily_overview", rowSchema: DailyOverviewSchema, version: "version" }),
|
|
23
|
+
Demo.table({ name: "traffic_sources", rowSchema: TrafficSourceSchema, version: "version" }),
|
|
24
|
+
Demo.table({
|
|
25
|
+
name: "user_acquisition",
|
|
26
|
+
rowSchema: UserAcquisitionSchema,
|
|
27
|
+
version: "version",
|
|
28
|
+
}),
|
|
29
|
+
Demo.table({ name: "pages", rowSchema: PageSchema, version: "version" }),
|
|
30
|
+
Demo.table({ name: "landing_pages", rowSchema: LandingPageSchema, version: "version" }),
|
|
31
|
+
Demo.table({ name: "events", rowSchema: EventSchema, version: "version" }),
|
|
32
|
+
Demo.table({ name: "locations", rowSchema: LocationSchema, version: "version" }),
|
|
33
|
+
Demo.table({ name: "devices", rowSchema: DeviceSchema, version: "version" }),
|
|
34
|
+
Demo.table({ name: "ecommerce_items", rowSchema: EcommerceItemSchema, version: "version" }),
|
|
35
|
+
],
|
|
36
|
+
rows: dayRows,
|
|
37
|
+
// Two years of days, the same as the default GA_START_DATE.
|
|
38
|
+
count: 730,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const demoCommand = ConnectorApp.makeDemoCommand(demo).pipe(
|
|
43
|
+
Command.provide(RuntimeConfig.layerHosted()),
|
|
44
|
+
);
|