@useairfoil/producer-google-analytics 0.0.1 → 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 +75 -0
- 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 +45 -7
- 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,169 @@
|
|
|
1
|
+
import { ConnectorError, Cursor, Fetch } from "@useairfoil/connector-kit";
|
|
2
|
+
import { DateTime, Duration, Effect, Option, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { GoogleAnalyticsClientService } from "../../client/client";
|
|
5
|
+
import type { GoogleAnalyticsConfig } from "../../manifest";
|
|
6
|
+
import type { ReportSpec } from "./spec";
|
|
7
|
+
|
|
8
|
+
import { readDay } from "./read";
|
|
9
|
+
|
|
10
|
+
// Daily data is ready about 12 hours after the day ends, so polling more often
|
|
11
|
+
// mostly reads the same numbers.
|
|
12
|
+
const changesInterval = Duration.hours(6);
|
|
13
|
+
|
|
14
|
+
type ReportConfig = Pick<GoogleAnalyticsConfig, "propertyId" | "startDate" | "lookbackDays">;
|
|
15
|
+
|
|
16
|
+
const Day = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/));
|
|
17
|
+
|
|
18
|
+
const addDays = (day: string, days: number) =>
|
|
19
|
+
DateTime.formatIsoDateUtc(DateTime.add(DateTime.makeUnsafe(day), { days }));
|
|
20
|
+
|
|
21
|
+
/** The calendar day of `time` in the property's time zone. */
|
|
22
|
+
const dayIn = (zone: DateTime.TimeZone, time: DateTime.DateTime) =>
|
|
23
|
+
DateTime.formatIsoDate(DateTime.setZone(time, zone));
|
|
24
|
+
|
|
25
|
+
const minDay = (a: string, b: string) => (a < b ? a : b);
|
|
26
|
+
|
|
27
|
+
/** Reports cover whole days, so backfill ends the day before the cutoff. */
|
|
28
|
+
const lastBackfillDay = (zone: DateTime.TimeZone, cutoff: DateTime.Utc) =>
|
|
29
|
+
addDays(dayIn(zone, cutoff), -1);
|
|
30
|
+
|
|
31
|
+
// The API rejects earlier days.
|
|
32
|
+
const firstDay = "2015-08-14";
|
|
33
|
+
|
|
34
|
+
const startDay = (config: ReportConfig, end: string) =>
|
|
35
|
+
Option.match(config.startDate, {
|
|
36
|
+
onNone: () =>
|
|
37
|
+
Effect.succeed(
|
|
38
|
+
DateTime.formatIsoDateUtc(DateTime.add(DateTime.makeUnsafe(end), { years: -2 })),
|
|
39
|
+
),
|
|
40
|
+
onSome: (startDate) =>
|
|
41
|
+
Schema.decodeUnknownEffect(Day)(startDate).pipe(
|
|
42
|
+
Effect.mapError(
|
|
43
|
+
(cause) => new ConnectorError({ message: "GA_START_DATE must be YYYY-MM-DD", cause }),
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
}).pipe(Effect.map((day) => (day < firstDay ? firstDay : day)));
|
|
47
|
+
|
|
48
|
+
const cursorError = (cause: Schema.SchemaError) =>
|
|
49
|
+
new ConnectorError({ message: "Invalid Google Analytics cursor", cause });
|
|
50
|
+
|
|
51
|
+
const PageCursorSchema = Schema.fromJsonString(Schema.Struct({ day: Day, offset: Schema.Int }));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `through` is the last day fully read. `pending` is set while a run is split:
|
|
55
|
+
* the day and offset to go on from, and the last day of that run.
|
|
56
|
+
*/
|
|
57
|
+
const ChangesCursorSchema = Schema.Struct({
|
|
58
|
+
through: Day,
|
|
59
|
+
pending: Schema.optional(Schema.Struct({ day: Day, end: Day, offset: Schema.Int })),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
type ChangesCursor = Schema.Schema.Type<typeof ChangesCursorSchema>;
|
|
63
|
+
|
|
64
|
+
// The first cursor is the backfill cutoff, a plain ISO time.
|
|
65
|
+
const ChangesCursorCodec = Schema.Union([
|
|
66
|
+
Schema.fromJsonString(ChangesCursorSchema),
|
|
67
|
+
Schema.DateTimeUtcFromString,
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
export const reportCheck = <Row extends object>(
|
|
71
|
+
client: GoogleAnalyticsClientService,
|
|
72
|
+
config: ReportConfig,
|
|
73
|
+
spec: ReportSpec<Row>,
|
|
74
|
+
) =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const zone = yield* client.timeZone;
|
|
77
|
+
const yesterday = addDays(dayIn(zone, yield* DateTime.now), -1);
|
|
78
|
+
yield* startDay(config, yesterday);
|
|
79
|
+
yield* readDay(client, config.propertyId, spec, { day: yesterday, offset: 0, limit: 1 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const reportBackfill = <Row extends object>(
|
|
83
|
+
client: GoogleAnalyticsClientService,
|
|
84
|
+
config: ReportConfig,
|
|
85
|
+
spec: ReportSpec<Row>,
|
|
86
|
+
) =>
|
|
87
|
+
Fetch.page({
|
|
88
|
+
pageCursor: Cursor.string(),
|
|
89
|
+
cutoff: Cursor.isoDateTime(),
|
|
90
|
+
fetch: ({ pageCursor, cutoff }) =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const zone = yield* client.timeZone;
|
|
93
|
+
const cutoffTime = yield* Schema.decodeUnknownEffect(Schema.DateTimeUtcFromString)(
|
|
94
|
+
String(cutoff),
|
|
95
|
+
).pipe(Effect.mapError(cursorError));
|
|
96
|
+
const end = lastBackfillDay(zone, cutoffTime);
|
|
97
|
+
const at =
|
|
98
|
+
pageCursor === undefined
|
|
99
|
+
? { day: yield* startDay(config, end), offset: 0 }
|
|
100
|
+
: yield* Schema.decodeUnknownEffect(PageCursorSchema)(String(pageCursor)).pipe(
|
|
101
|
+
Effect.mapError(cursorError),
|
|
102
|
+
);
|
|
103
|
+
if (at.day > end) return { rows: [], hasMore: false };
|
|
104
|
+
|
|
105
|
+
const page = yield* readDay(client, config.propertyId, spec, at);
|
|
106
|
+
const next = Option.match(page.next, {
|
|
107
|
+
onSome: (offset) => ({ day: at.day, offset }),
|
|
108
|
+
onNone: () => ({ day: addDays(at.day, 1), offset: 0 }),
|
|
109
|
+
});
|
|
110
|
+
if (next.day > end) return { rows: page.rows, hasMore: false };
|
|
111
|
+
return {
|
|
112
|
+
rows: page.rows,
|
|
113
|
+
hasMore: true,
|
|
114
|
+
nextPageCursor: yield* Schema.encodeEffect(PageCursorSchema)(next).pipe(
|
|
115
|
+
Effect.mapError(cursorError),
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reads the last `lookbackDays` again on every run, since Google keeps
|
|
123
|
+
* updating recent days. After downtime it also reads the days it missed.
|
|
124
|
+
*/
|
|
125
|
+
export const reportChanges = <Row extends object>(
|
|
126
|
+
client: GoogleAnalyticsClientService,
|
|
127
|
+
config: ReportConfig,
|
|
128
|
+
spec: ReportSpec<Row>,
|
|
129
|
+
) =>
|
|
130
|
+
Fetch.changes({
|
|
131
|
+
cursor: Cursor.string(),
|
|
132
|
+
interval: changesInterval,
|
|
133
|
+
fetch: ({ cursor }) =>
|
|
134
|
+
Effect.gen(function* () {
|
|
135
|
+
const zone = yield* client.timeZone;
|
|
136
|
+
const decoded = yield* Schema.decodeUnknownEffect(ChangesCursorCodec)(String(cursor)).pipe(
|
|
137
|
+
Effect.mapError(cursorError),
|
|
138
|
+
);
|
|
139
|
+
const state: ChangesCursor = DateTime.isDateTime(decoded)
|
|
140
|
+
? { through: lastBackfillDay(zone, decoded) }
|
|
141
|
+
: decoded;
|
|
142
|
+
const yesterday = addDays(dayIn(zone, yield* DateTime.now), -1);
|
|
143
|
+
const window = state.pending ?? {
|
|
144
|
+
day: minDay(addDays(state.through, 1), addDays(yesterday, 1 - config.lookbackDays)),
|
|
145
|
+
end: yesterday,
|
|
146
|
+
offset: 0,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const page = yield* readDay(client, config.propertyId, spec, window);
|
|
150
|
+
const pending = Option.match(page.next, {
|
|
151
|
+
onSome: (offset) => Option.some({ ...window, offset }),
|
|
152
|
+
onNone: () =>
|
|
153
|
+
window.day < window.end
|
|
154
|
+
? Option.some({ ...window, day: addDays(window.day, 1), offset: 0 })
|
|
155
|
+
: Option.none(),
|
|
156
|
+
});
|
|
157
|
+
const next: ChangesCursor = Option.match(pending, {
|
|
158
|
+
onSome: (pending) => ({ through: state.through, pending }),
|
|
159
|
+
onNone: () => ({ through: window.end }),
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
rows: page.rows,
|
|
163
|
+
cursor: yield* Schema.encodeEffect(Schema.fromJsonString(ChangesCursorSchema))(next).pipe(
|
|
164
|
+
Effect.mapError(cursorError),
|
|
165
|
+
),
|
|
166
|
+
hasMore: Option.isSome(pending),
|
|
167
|
+
};
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Identified } from "@useairfoil/connector-kit";
|
|
2
|
+
import type { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
/** API names by column, in request order. */
|
|
5
|
+
export type Columns<Row> = { readonly [Column in keyof Row & string]?: string };
|
|
6
|
+
|
|
7
|
+
export type ReportSpec<Row extends object> = {
|
|
8
|
+
/** Resource name, such as `traffic_sources`. */
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** Every report also asks for `date`. */
|
|
11
|
+
readonly dimensions: Columns<Row>;
|
|
12
|
+
/** At most 10. */
|
|
13
|
+
readonly metrics: Columns<Row>;
|
|
14
|
+
readonly rowSchema: Schema.Decoder<Row> & Identified<"id">;
|
|
15
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ReportSpec } from "./spec";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type DailyOverview,
|
|
5
|
+
DailyOverviewSchema,
|
|
6
|
+
type Device,
|
|
7
|
+
DeviceSchema,
|
|
8
|
+
type EcommerceItem,
|
|
9
|
+
EcommerceItemSchema,
|
|
10
|
+
type Event,
|
|
11
|
+
EventSchema,
|
|
12
|
+
type LandingPage,
|
|
13
|
+
LandingPageSchema,
|
|
14
|
+
type Location,
|
|
15
|
+
LocationSchema,
|
|
16
|
+
type Page,
|
|
17
|
+
PageSchema,
|
|
18
|
+
type TrafficSource,
|
|
19
|
+
TrafficSourceSchema,
|
|
20
|
+
type UserAcquisition,
|
|
21
|
+
UserAcquisitionSchema,
|
|
22
|
+
} from "../../schemas/reports";
|
|
23
|
+
|
|
24
|
+
// Every pair here is one Google shows together in its own reports. Check new
|
|
25
|
+
// ones with `checkCompatibility` first.
|
|
26
|
+
|
|
27
|
+
export const dailyOverview: ReportSpec<DailyOverview> = {
|
|
28
|
+
name: "daily_overview",
|
|
29
|
+
dimensions: {},
|
|
30
|
+
metrics: {
|
|
31
|
+
active_users: "activeUsers",
|
|
32
|
+
new_users: "newUsers",
|
|
33
|
+
total_users: "totalUsers",
|
|
34
|
+
sessions: "sessions",
|
|
35
|
+
engaged_sessions: "engagedSessions",
|
|
36
|
+
screen_page_views: "screenPageViews",
|
|
37
|
+
event_count: "eventCount",
|
|
38
|
+
key_events: "keyEvents",
|
|
39
|
+
total_revenue: "totalRevenue",
|
|
40
|
+
user_engagement_duration: "userEngagementDuration",
|
|
41
|
+
},
|
|
42
|
+
rowSchema: DailyOverviewSchema,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const trafficSources: ReportSpec<TrafficSource> = {
|
|
46
|
+
name: "traffic_sources",
|
|
47
|
+
dimensions: {
|
|
48
|
+
session_source: "sessionSource",
|
|
49
|
+
session_medium: "sessionMedium",
|
|
50
|
+
session_campaign_name: "sessionCampaignName",
|
|
51
|
+
session_default_channel_group: "sessionDefaultChannelGroup",
|
|
52
|
+
},
|
|
53
|
+
metrics: {
|
|
54
|
+
sessions: "sessions",
|
|
55
|
+
engaged_sessions: "engagedSessions",
|
|
56
|
+
active_users: "activeUsers",
|
|
57
|
+
new_users: "newUsers",
|
|
58
|
+
screen_page_views: "screenPageViews",
|
|
59
|
+
event_count: "eventCount",
|
|
60
|
+
key_events: "keyEvents",
|
|
61
|
+
total_revenue: "totalRevenue",
|
|
62
|
+
user_engagement_duration: "userEngagementDuration",
|
|
63
|
+
},
|
|
64
|
+
rowSchema: TrafficSourceSchema,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const userAcquisition: ReportSpec<UserAcquisition> = {
|
|
68
|
+
name: "user_acquisition",
|
|
69
|
+
dimensions: {
|
|
70
|
+
first_user_source: "firstUserSource",
|
|
71
|
+
first_user_medium: "firstUserMedium",
|
|
72
|
+
first_user_campaign_name: "firstUserCampaignName",
|
|
73
|
+
first_user_default_channel_group: "firstUserDefaultChannelGroup",
|
|
74
|
+
},
|
|
75
|
+
metrics: {
|
|
76
|
+
new_users: "newUsers",
|
|
77
|
+
active_users: "activeUsers",
|
|
78
|
+
sessions: "sessions",
|
|
79
|
+
engaged_sessions: "engagedSessions",
|
|
80
|
+
key_events: "keyEvents",
|
|
81
|
+
total_revenue: "totalRevenue",
|
|
82
|
+
},
|
|
83
|
+
rowSchema: UserAcquisitionSchema,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const pages: ReportSpec<Page> = {
|
|
87
|
+
name: "pages",
|
|
88
|
+
dimensions: { host_name: "hostName", page_path: "pagePath", page_title: "pageTitle" },
|
|
89
|
+
metrics: {
|
|
90
|
+
screen_page_views: "screenPageViews",
|
|
91
|
+
active_users: "activeUsers",
|
|
92
|
+
event_count: "eventCount",
|
|
93
|
+
key_events: "keyEvents",
|
|
94
|
+
user_engagement_duration: "userEngagementDuration",
|
|
95
|
+
},
|
|
96
|
+
rowSchema: PageSchema,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const landingPages: ReportSpec<LandingPage> = {
|
|
100
|
+
name: "landing_pages",
|
|
101
|
+
dimensions: { landing_page: "landingPage" },
|
|
102
|
+
metrics: {
|
|
103
|
+
sessions: "sessions",
|
|
104
|
+
engaged_sessions: "engagedSessions",
|
|
105
|
+
active_users: "activeUsers",
|
|
106
|
+
new_users: "newUsers",
|
|
107
|
+
key_events: "keyEvents",
|
|
108
|
+
total_revenue: "totalRevenue",
|
|
109
|
+
},
|
|
110
|
+
rowSchema: LandingPageSchema,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const events: ReportSpec<Event> = {
|
|
114
|
+
name: "events",
|
|
115
|
+
dimensions: { event_name: "eventName" },
|
|
116
|
+
metrics: {
|
|
117
|
+
event_count: "eventCount",
|
|
118
|
+
total_users: "totalUsers",
|
|
119
|
+
event_value: "eventValue",
|
|
120
|
+
key_events: "keyEvents",
|
|
121
|
+
},
|
|
122
|
+
rowSchema: EventSchema,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const locations: ReportSpec<Location> = {
|
|
126
|
+
name: "locations",
|
|
127
|
+
dimensions: { country: "country", region: "region", city: "city" },
|
|
128
|
+
metrics: {
|
|
129
|
+
active_users: "activeUsers",
|
|
130
|
+
new_users: "newUsers",
|
|
131
|
+
sessions: "sessions",
|
|
132
|
+
engaged_sessions: "engagedSessions",
|
|
133
|
+
key_events: "keyEvents",
|
|
134
|
+
total_revenue: "totalRevenue",
|
|
135
|
+
},
|
|
136
|
+
rowSchema: LocationSchema,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const devices: ReportSpec<Device> = {
|
|
140
|
+
name: "devices",
|
|
141
|
+
dimensions: {
|
|
142
|
+
device_category: "deviceCategory",
|
|
143
|
+
platform: "platform",
|
|
144
|
+
operating_system: "operatingSystem",
|
|
145
|
+
browser: "browser",
|
|
146
|
+
},
|
|
147
|
+
metrics: {
|
|
148
|
+
active_users: "activeUsers",
|
|
149
|
+
new_users: "newUsers",
|
|
150
|
+
sessions: "sessions",
|
|
151
|
+
engaged_sessions: "engagedSessions",
|
|
152
|
+
screen_page_views: "screenPageViews",
|
|
153
|
+
key_events: "keyEvents",
|
|
154
|
+
},
|
|
155
|
+
rowSchema: DeviceSchema,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const ecommerceItems: ReportSpec<EcommerceItem> = {
|
|
159
|
+
name: "ecommerce_items",
|
|
160
|
+
dimensions: {
|
|
161
|
+
item_id: "itemId",
|
|
162
|
+
item_name: "itemName",
|
|
163
|
+
item_brand: "itemBrand",
|
|
164
|
+
item_category: "itemCategory",
|
|
165
|
+
},
|
|
166
|
+
metrics: {
|
|
167
|
+
items_viewed: "itemsViewed",
|
|
168
|
+
items_added_to_cart: "itemsAddedToCart",
|
|
169
|
+
items_purchased: "itemsPurchased",
|
|
170
|
+
item_revenue: "itemRevenue",
|
|
171
|
+
},
|
|
172
|
+
rowSchema: EcommerceItemSchema,
|
|
173
|
+
};
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectorApp,
|
|
3
|
+
Ingestor,
|
|
4
|
+
RuntimeConfig,
|
|
5
|
+
StateStore,
|
|
6
|
+
Telemetry,
|
|
7
|
+
} from "@useairfoil/connector-kit";
|
|
8
|
+
import { Effect, Layer, Logger } from "effect";
|
|
9
|
+
import { Command } from "effect/cli";
|
|
10
|
+
|
|
11
|
+
import { GoogleAnalyticsConnector } from "./index";
|
|
12
|
+
|
|
13
|
+
const ConnectorLayer = GoogleAnalyticsConnector.layerConfig(
|
|
14
|
+
GoogleAnalyticsConnector.GoogleAnalyticsConfigDef.config,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const TelemetryLayer = Layer.mergeAll(Telemetry.layerOtlp(), Telemetry.layerMetricsConsoleDump());
|
|
18
|
+
|
|
19
|
+
export const sandboxCommand = Command.make("sandbox", {}, () =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const port = yield* RuntimeConfig.httpPort;
|
|
22
|
+
const entrypoint = yield* GoogleAnalyticsConnector.GoogleAnalyticsConnector;
|
|
23
|
+
|
|
24
|
+
return yield* ConnectorApp.start(entrypoint, { port });
|
|
25
|
+
}).pipe(
|
|
26
|
+
Effect.annotateLogs({ component: "google-analytics" }),
|
|
27
|
+
Effect.provide(
|
|
28
|
+
Layer.mergeAll(
|
|
29
|
+
StateStore.layerMemory,
|
|
30
|
+
Ingestor.layerConsole,
|
|
31
|
+
ConnectorLayer,
|
|
32
|
+
Logger.layer([Logger.consolePretty()]),
|
|
33
|
+
TelemetryLayer,
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
).pipe(Command.withDescription("Run the connector locally and log ingested data"));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const ValueSchema = Schema.Struct({ value: Schema.String });
|
|
4
|
+
|
|
5
|
+
/** `POST properties/{id}:runReport`. Every value comes as a string. */
|
|
6
|
+
export const RunReportResponseSchema = Schema.Struct({
|
|
7
|
+
dimensionHeaders: Schema.Array(Schema.Struct({ name: Schema.String })),
|
|
8
|
+
metricHeaders: Schema.Array(Schema.Struct({ name: Schema.String })),
|
|
9
|
+
rows: Schema.optional(
|
|
10
|
+
Schema.Array(
|
|
11
|
+
Schema.Struct({
|
|
12
|
+
dimensionValues: Schema.Array(ValueSchema),
|
|
13
|
+
metricValues: Schema.Array(ValueSchema),
|
|
14
|
+
}),
|
|
15
|
+
),
|
|
16
|
+
),
|
|
17
|
+
// Left out when the report is empty.
|
|
18
|
+
rowCount: Schema.optional(Schema.Int),
|
|
19
|
+
metadata: Schema.Struct({
|
|
20
|
+
timeZone: Schema.String,
|
|
21
|
+
currencyCode: Schema.optional(Schema.String),
|
|
22
|
+
dataLossFromOtherRow: Schema.optional(Schema.Boolean),
|
|
23
|
+
subjectToThresholding: Schema.optional(Schema.Boolean),
|
|
24
|
+
samplingMetadatas: Schema.optional(Schema.Array(Schema.Unknown)),
|
|
25
|
+
dataTruncationReasons: Schema.optional(Schema.Array(Schema.Unknown)),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type RunReportResponse = Schema.Schema.Type<typeof RunReportResponseSchema>;
|
|
30
|
+
|
|
31
|
+
export const ErrorBodySchema = Schema.Struct({
|
|
32
|
+
error: Schema.Struct({
|
|
33
|
+
code: Schema.optional(Schema.Int),
|
|
34
|
+
message: Schema.optional(Schema.String),
|
|
35
|
+
status: Schema.optional(Schema.String),
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Iceberg } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
import { amount, common, count, currencyCode, dimension } from "./shared";
|
|
5
|
+
|
|
6
|
+
// User counts are distinct users, so they don't add up across rows or days.
|
|
7
|
+
|
|
8
|
+
export const DailyOverviewSchema = Schema.Struct({
|
|
9
|
+
...common,
|
|
10
|
+
active_users: count(30, "Users who engaged on the day."),
|
|
11
|
+
new_users: count(31, "Users who came for the first time."),
|
|
12
|
+
total_users: count(32, "Users who logged any event."),
|
|
13
|
+
sessions: count(33, "Sessions that started."),
|
|
14
|
+
engaged_sessions: count(
|
|
15
|
+
34,
|
|
16
|
+
"Sessions that lasted 10 seconds or more, or had a key event or 2 page views.",
|
|
17
|
+
),
|
|
18
|
+
screen_page_views: count(35, "Page and screen views."),
|
|
19
|
+
event_count: count(36, "Events."),
|
|
20
|
+
key_events: amount(37, "Key events. Can be fractional after attribution."),
|
|
21
|
+
total_revenue: amount(38, "Revenue from purchases, subscriptions, and ads."),
|
|
22
|
+
user_engagement_duration: amount(39, "Seconds the site or app was in the foreground."),
|
|
23
|
+
currency_code: currencyCode,
|
|
24
|
+
})
|
|
25
|
+
.annotate({ description: "Google Analytics totals per day." })
|
|
26
|
+
.pipe(Iceberg.identifier("id"));
|
|
27
|
+
|
|
28
|
+
export type DailyOverview = Schema.Schema.Type<typeof DailyOverviewSchema>;
|
|
29
|
+
|
|
30
|
+
export const TrafficSourceSchema = Schema.Struct({
|
|
31
|
+
...common,
|
|
32
|
+
session_source: dimension(10, "Source of the session, such as google."),
|
|
33
|
+
session_medium: dimension(11, "Medium of the session, such as organic."),
|
|
34
|
+
session_campaign_name: dimension(12, "Campaign of the session."),
|
|
35
|
+
session_default_channel_group: dimension(13, "Default channel group of the session."),
|
|
36
|
+
sessions: count(30, "Sessions that started."),
|
|
37
|
+
engaged_sessions: count(31, "Engaged sessions."),
|
|
38
|
+
active_users: count(32, "Users who engaged."),
|
|
39
|
+
new_users: count(33, "Users who came for the first time."),
|
|
40
|
+
screen_page_views: count(34, "Page and screen views."),
|
|
41
|
+
event_count: count(35, "Events."),
|
|
42
|
+
key_events: amount(36, "Key events."),
|
|
43
|
+
total_revenue: amount(37, "Revenue from purchases, subscriptions, and ads."),
|
|
44
|
+
user_engagement_duration: amount(38, "Seconds the site or app was in the foreground."),
|
|
45
|
+
currency_code: currencyCode,
|
|
46
|
+
})
|
|
47
|
+
.annotate({ description: "Google Analytics sessions per day and traffic source." })
|
|
48
|
+
.pipe(Iceberg.identifier("id"));
|
|
49
|
+
|
|
50
|
+
export type TrafficSource = Schema.Schema.Type<typeof TrafficSourceSchema>;
|
|
51
|
+
|
|
52
|
+
export const UserAcquisitionSchema = Schema.Struct({
|
|
53
|
+
...common,
|
|
54
|
+
first_user_source: dimension(10, "Source that first brought the user."),
|
|
55
|
+
first_user_medium: dimension(11, "Medium that first brought the user."),
|
|
56
|
+
first_user_campaign_name: dimension(12, "Campaign that first brought the user."),
|
|
57
|
+
first_user_default_channel_group: dimension(
|
|
58
|
+
13,
|
|
59
|
+
"Default channel group that first brought the user.",
|
|
60
|
+
),
|
|
61
|
+
new_users: count(30, "Users who came for the first time."),
|
|
62
|
+
active_users: count(31, "Users who engaged."),
|
|
63
|
+
sessions: count(32, "Sessions that started."),
|
|
64
|
+
engaged_sessions: count(33, "Engaged sessions."),
|
|
65
|
+
key_events: amount(34, "Key events."),
|
|
66
|
+
total_revenue: amount(35, "Revenue from purchases, subscriptions, and ads."),
|
|
67
|
+
currency_code: currencyCode,
|
|
68
|
+
})
|
|
69
|
+
.annotate({ description: "Google Analytics users per day and first traffic source." })
|
|
70
|
+
.pipe(Iceberg.identifier("id"));
|
|
71
|
+
|
|
72
|
+
export type UserAcquisition = Schema.Schema.Type<typeof UserAcquisitionSchema>;
|
|
73
|
+
|
|
74
|
+
export const PageSchema = Schema.Struct({
|
|
75
|
+
...common,
|
|
76
|
+
host_name: dimension(10, "Host name, such as www.example.com."),
|
|
77
|
+
page_path: dimension(11, "Page path, without the query string."),
|
|
78
|
+
page_title: dimension(12, "Page title."),
|
|
79
|
+
screen_page_views: count(30, "Page and screen views."),
|
|
80
|
+
active_users: count(31, "Users who engaged."),
|
|
81
|
+
event_count: count(32, "Events."),
|
|
82
|
+
key_events: amount(33, "Key events."),
|
|
83
|
+
user_engagement_duration: amount(34, "Seconds the page was in the foreground."),
|
|
84
|
+
})
|
|
85
|
+
.annotate({ description: "Google Analytics page views per day and page." })
|
|
86
|
+
.pipe(Iceberg.identifier("id"));
|
|
87
|
+
|
|
88
|
+
export type Page = Schema.Schema.Type<typeof PageSchema>;
|
|
89
|
+
|
|
90
|
+
export const LandingPageSchema = Schema.Struct({
|
|
91
|
+
...common,
|
|
92
|
+
landing_page: dimension(10, "First page path of the session, without the query string."),
|
|
93
|
+
sessions: count(30, "Sessions that started."),
|
|
94
|
+
engaged_sessions: count(31, "Engaged sessions."),
|
|
95
|
+
active_users: count(32, "Users who engaged."),
|
|
96
|
+
new_users: count(33, "Users who came for the first time."),
|
|
97
|
+
key_events: amount(34, "Key events."),
|
|
98
|
+
total_revenue: amount(35, "Revenue from purchases, subscriptions, and ads."),
|
|
99
|
+
currency_code: currencyCode,
|
|
100
|
+
})
|
|
101
|
+
.annotate({ description: "Google Analytics sessions per day and landing page." })
|
|
102
|
+
.pipe(Iceberg.identifier("id"));
|
|
103
|
+
|
|
104
|
+
export type LandingPage = Schema.Schema.Type<typeof LandingPageSchema>;
|
|
105
|
+
|
|
106
|
+
export const EventSchema = Schema.Struct({
|
|
107
|
+
...common,
|
|
108
|
+
event_name: dimension(10, "Event name, such as page_view."),
|
|
109
|
+
event_count: count(30, "Events."),
|
|
110
|
+
total_users: count(31, "Users who logged the event."),
|
|
111
|
+
event_value: amount(32, "Sum of the event's value parameter."),
|
|
112
|
+
key_events: amount(33, "Key events."),
|
|
113
|
+
})
|
|
114
|
+
.annotate({ description: "Google Analytics events per day and event name." })
|
|
115
|
+
.pipe(Iceberg.identifier("id"));
|
|
116
|
+
|
|
117
|
+
export type Event = Schema.Schema.Type<typeof EventSchema>;
|
|
118
|
+
|
|
119
|
+
export const LocationSchema = Schema.Struct({
|
|
120
|
+
...common,
|
|
121
|
+
country: dimension(10, "Country of the user."),
|
|
122
|
+
region: dimension(11, "Region of the user."),
|
|
123
|
+
city: dimension(12, "City of the user."),
|
|
124
|
+
active_users: count(30, "Users who engaged."),
|
|
125
|
+
new_users: count(31, "Users who came for the first time."),
|
|
126
|
+
sessions: count(32, "Sessions that started."),
|
|
127
|
+
engaged_sessions: count(33, "Engaged sessions."),
|
|
128
|
+
key_events: amount(34, "Key events."),
|
|
129
|
+
total_revenue: amount(35, "Revenue from purchases, subscriptions, and ads."),
|
|
130
|
+
currency_code: currencyCode,
|
|
131
|
+
})
|
|
132
|
+
.annotate({ description: "Google Analytics users per day and location." })
|
|
133
|
+
.pipe(Iceberg.identifier("id"));
|
|
134
|
+
|
|
135
|
+
export type Location = Schema.Schema.Type<typeof LocationSchema>;
|
|
136
|
+
|
|
137
|
+
export const DeviceSchema = Schema.Struct({
|
|
138
|
+
...common,
|
|
139
|
+
device_category: dimension(10, "Device category: desktop, mobile, or tablet."),
|
|
140
|
+
platform: dimension(11, "Platform: web, iOS, or Android."),
|
|
141
|
+
operating_system: dimension(12, "Operating system."),
|
|
142
|
+
browser: dimension(13, "Browser."),
|
|
143
|
+
active_users: count(30, "Users who engaged."),
|
|
144
|
+
new_users: count(31, "Users who came for the first time."),
|
|
145
|
+
sessions: count(32, "Sessions that started."),
|
|
146
|
+
engaged_sessions: count(33, "Engaged sessions."),
|
|
147
|
+
screen_page_views: count(34, "Page and screen views."),
|
|
148
|
+
key_events: amount(35, "Key events."),
|
|
149
|
+
})
|
|
150
|
+
.annotate({ description: "Google Analytics users per day and device." })
|
|
151
|
+
.pipe(Iceberg.identifier("id"));
|
|
152
|
+
|
|
153
|
+
export type Device = Schema.Schema.Type<typeof DeviceSchema>;
|
|
154
|
+
|
|
155
|
+
export const EcommerceItemSchema = Schema.Struct({
|
|
156
|
+
...common,
|
|
157
|
+
item_id: dimension(10, "Item ID."),
|
|
158
|
+
item_name: dimension(11, "Item name."),
|
|
159
|
+
item_brand: dimension(12, "Item brand."),
|
|
160
|
+
item_category: dimension(13, "First item category."),
|
|
161
|
+
items_viewed: count(30, "Units viewed."),
|
|
162
|
+
items_added_to_cart: count(31, "Units added to carts."),
|
|
163
|
+
items_purchased: count(32, "Units purchased."),
|
|
164
|
+
item_revenue: amount(33, "Revenue from the item, without tax and shipping."),
|
|
165
|
+
currency_code: currencyCode,
|
|
166
|
+
})
|
|
167
|
+
.annotate({
|
|
168
|
+
description: "Google Analytics ecommerce items per day. Empty without ecommerce events.",
|
|
169
|
+
})
|
|
170
|
+
.pipe(Iceberg.identifier("id"));
|
|
171
|
+
|
|
172
|
+
export type EcommerceItem = Schema.Schema.Type<typeof EcommerceItemSchema>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Iceberg } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Schema, SchemaTransformation } from "effect";
|
|
3
|
+
|
|
4
|
+
export const field = (fieldId: number, description: string) =>
|
|
5
|
+
Iceberg.field(fieldId, { description });
|
|
6
|
+
|
|
7
|
+
/** The API's `YYYYMMDD` as `YYYY-MM-DD`. */
|
|
8
|
+
export const isoDay = (date: string) => `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6)}`;
|
|
9
|
+
|
|
10
|
+
const ReportDate = Schema.String.check(Schema.isPattern(/^\d{8}$/)).pipe(
|
|
11
|
+
Schema.decodeTo(
|
|
12
|
+
Schema.String,
|
|
13
|
+
SchemaTransformation.transform({
|
|
14
|
+
decode: isoDay,
|
|
15
|
+
encode: (date) => date.replaceAll("-", ""),
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/** `TYPE_INTEGER` metrics, as an Iceberg long. */
|
|
21
|
+
export const count = (fieldId: number, description: string) =>
|
|
22
|
+
Schema.BigIntFromString.pipe(field(fieldId, description));
|
|
23
|
+
|
|
24
|
+
/** Float, currency, and seconds metrics, as an Iceberg double. */
|
|
25
|
+
export const amount = (fieldId: number, description: string) =>
|
|
26
|
+
Schema.FiniteFromString.pipe(field(fieldId, description));
|
|
27
|
+
|
|
28
|
+
/** Dimension values are never null. GA uses `(not set)` and `(other)` instead. */
|
|
29
|
+
export const dimension = (fieldId: number, description: string) =>
|
|
30
|
+
Schema.String.pipe(field(fieldId, description));
|
|
31
|
+
|
|
32
|
+
/** `version` is the fetch time, since report rows have no update time. */
|
|
33
|
+
export const common = {
|
|
34
|
+
id: Schema.String.pipe(field(1, "JSON array of the property ID, date, and dimension values.")),
|
|
35
|
+
version: Schema.Date.pipe(field(2, "Time the row was read from Google Analytics.")),
|
|
36
|
+
property_id: Schema.String.pipe(field(3, "GA4 property ID.")),
|
|
37
|
+
date: ReportDate.pipe(field(4, "Day in the property's time zone, as YYYY-MM-DD.")),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const currencyCode = Schema.String.pipe(
|
|
41
|
+
field(50, "Currency of the revenue fields, the property's currency."),
|
|
42
|
+
);
|