@socialgouv/matomo-postgres 1.1.4
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/.github/workflows/release.yml +33 -0
- package/.github/workflows/test.yml +27 -0
- package/README.md +40 -0
- package/bin/index.js +11 -0
- package/docker-compose.yml +10 -0
- package/jsconfig.json +10 -0
- package/package.json +36 -0
- package/renovate.json +6 -0
- package/src/__tests__/__snapshots__/importDate.test.js.snap +189 -0
- package/src/__tests__/__snapshots__/importEvent.test.js.snap +165 -0
- package/src/__tests__/__snapshots__/index.test.js.snap +36 -0
- package/src/__tests__/importDate.test.js +133 -0
- package/src/__tests__/importEvent.test.js +22 -0
- package/src/__tests__/index.test.js +198 -0
- package/src/__tests__/visit.json +74 -0
- package/src/config.js +9 -0
- package/src/createTable.js +48 -0
- package/src/importDate.js +105 -0
- package/src/importEvent.js +99 -0
- package/src/index.js +79 -0
- package/types/index.d.ts +30 -0
- package/types/matomo.d.ts +208 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const { importDate } = require("../importDate");
|
|
2
|
+
|
|
3
|
+
const mock_pgQuery = jest.fn();
|
|
4
|
+
const mock_matomoApi = jest.fn();
|
|
5
|
+
|
|
6
|
+
process.env.MATOMO_SITE = "42";
|
|
7
|
+
process.env.PROJECT_NAME = "some-project";
|
|
8
|
+
|
|
9
|
+
const matomoVisit = require("./visit.json");
|
|
10
|
+
|
|
11
|
+
jest.mock("pg", () => {
|
|
12
|
+
class Client {
|
|
13
|
+
escapeIdentifier(name) {
|
|
14
|
+
return name;
|
|
15
|
+
}
|
|
16
|
+
end() {}
|
|
17
|
+
connect() {
|
|
18
|
+
return Promise.resolve();
|
|
19
|
+
}
|
|
20
|
+
query(...args) {
|
|
21
|
+
return mock_pgQuery(...args);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
Client,
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
jest.mock("piwik-client", () => {
|
|
30
|
+
class MatomoClient {
|
|
31
|
+
api(...args) {
|
|
32
|
+
return mock_matomoApi(...args);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return MatomoClient;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
jest.resetAllMocks();
|
|
40
|
+
jest.resetModules();
|
|
41
|
+
process.env.STARTDATE = "";
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("importDate: should import all events for a given date", async () => {
|
|
45
|
+
const pgSpy = jest.fn().mockResolvedValue();
|
|
46
|
+
const matomoSpy = jest.fn().mockImplementation((options, cb) => {
|
|
47
|
+
return cb(null, [
|
|
48
|
+
{
|
|
49
|
+
...matomoVisit,
|
|
50
|
+
idVisit: 123,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
...matomoVisit,
|
|
54
|
+
idVisit: 124,
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
const fakeClient = {
|
|
59
|
+
escapeIdentifier(name) {
|
|
60
|
+
return name;
|
|
61
|
+
},
|
|
62
|
+
query: pgSpy,
|
|
63
|
+
};
|
|
64
|
+
//@ts-expect-error
|
|
65
|
+
await importDate(fakeClient, matomoSpy, new Date("2021-08-02"));
|
|
66
|
+
expect(matomoSpy.mock.calls.map((c) => c[0])).toMatchSnapshot();
|
|
67
|
+
expect(pgSpy.mock.calls).toMatchSnapshot();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("importDate: should import when new results", async () => {
|
|
71
|
+
const pgResult = { rows: [] };
|
|
72
|
+
const pgSpy = jest.fn().mockResolvedValue(pgResult);
|
|
73
|
+
const matomoSpy = jest.fn().mockImplementation((options, cb) => {
|
|
74
|
+
return cb(null, [
|
|
75
|
+
{
|
|
76
|
+
...matomoVisit,
|
|
77
|
+
idVisit: 123,
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
const fakeClient = {
|
|
82
|
+
query: pgSpy,
|
|
83
|
+
escapeIdentifier(name) {
|
|
84
|
+
return name;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
//@ts-expect-error
|
|
88
|
+
const imported = await importDate(fakeClient, matomoSpy, new Date("2021-08-03T00:00:00"));
|
|
89
|
+
expect(pgSpy.mock.calls.length).toEqual(3);
|
|
90
|
+
expect(imported.length).toEqual(2);
|
|
91
|
+
expect(matomoSpy.mock.calls[0][0].filter_offset).toEqual(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("importDate: should offset matomo calls when results already exist", async () => {
|
|
95
|
+
const pgResult = { rows: [{ count: 42 }] };
|
|
96
|
+
const pgSpy = jest.fn().mockResolvedValue(pgResult);
|
|
97
|
+
const matomoSpy = jest.fn().mockImplementation((options, cb) => {
|
|
98
|
+
return cb(null, [
|
|
99
|
+
{
|
|
100
|
+
...matomoVisit,
|
|
101
|
+
idVisit: 123,
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
const fakeClient = {
|
|
106
|
+
query: pgSpy,
|
|
107
|
+
escapeIdentifier(name) {
|
|
108
|
+
return name;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
//@ts-expect-error
|
|
112
|
+
const imported = await importDate(fakeClient, matomoSpy, new Date("2021-08-03T00:00:00"));
|
|
113
|
+
expect(pgSpy.mock.calls.length).toEqual(3);
|
|
114
|
+
expect(imported.length).toEqual(2);
|
|
115
|
+
expect(matomoSpy.mock.calls[0][0].filter_offset).toEqual(42);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("importDate: should NOT import when no matomo results", async () => {
|
|
119
|
+
const pgSpy = jest.fn().mockResolvedValue({ rows: [{ action_timestamp: 1627948800000 }] });
|
|
120
|
+
const matomoSpy = jest.fn().mockImplementation((options, cb) => {
|
|
121
|
+
return cb(null, []);
|
|
122
|
+
});
|
|
123
|
+
const fakeClient = {
|
|
124
|
+
query: pgSpy,
|
|
125
|
+
escapeIdentifier(name) {
|
|
126
|
+
return name;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
//@ts-expect-error
|
|
130
|
+
const imported = await importDate(fakeClient, matomoSpy, new Date("2021-08-03"));
|
|
131
|
+
expect(pgSpy.mock.calls.length).toEqual(1);
|
|
132
|
+
expect(imported.length).toEqual(0);
|
|
133
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
process.env.MATOMO_SITE = "42";
|
|
2
|
+
process.env.PROJECT_NAME = "some-project";
|
|
3
|
+
|
|
4
|
+
const matomoVisit = require("./visit.json");
|
|
5
|
+
const { getEventsFromMatomoVisit, importEvent } = require("../importEvent");
|
|
6
|
+
|
|
7
|
+
test("importEvent: should extract events from matomo visit actionsDetails and create insert queries", () => {
|
|
8
|
+
//@ts-expect-error
|
|
9
|
+
const visits = getEventsFromMatomoVisit(matomoVisit);
|
|
10
|
+
expect(visits).toMatchSnapshot();
|
|
11
|
+
const spy = jest.fn();
|
|
12
|
+
spy.mockResolvedValue(Promise.resolve());
|
|
13
|
+
const fakeClient = {
|
|
14
|
+
escapeIdentifier(name) {
|
|
15
|
+
return name;
|
|
16
|
+
},
|
|
17
|
+
query: spy,
|
|
18
|
+
};
|
|
19
|
+
//@ts-expect-error
|
|
20
|
+
visits.map((visit) => importEvent(fakeClient, visit));
|
|
21
|
+
expect(spy.mock.calls).toMatchSnapshot();
|
|
22
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const mock_pgQuery = jest.fn();
|
|
2
|
+
const mock_matomoApi = jest.fn();
|
|
3
|
+
const formatISO = require("date-fns/formatISO");
|
|
4
|
+
const addDays = require("date-fns/addDays");
|
|
5
|
+
|
|
6
|
+
const { OFFSET } = require("../config");
|
|
7
|
+
|
|
8
|
+
process.env.MATOMO_SITE = "42";
|
|
9
|
+
process.env.PROJECT_NAME = "some-project";
|
|
10
|
+
|
|
11
|
+
const matomoVisit = require("./visit.json");
|
|
12
|
+
|
|
13
|
+
const run = require("../index");
|
|
14
|
+
|
|
15
|
+
const TEST_DATE = new Date();
|
|
16
|
+
|
|
17
|
+
// @ts-ignore
|
|
18
|
+
const isoDate = (date) => formatISO(date, { representation: "date" });
|
|
19
|
+
|
|
20
|
+
jest.mock("pg", () => {
|
|
21
|
+
class Client {
|
|
22
|
+
escapeIdentifier(name) {
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
end() {}
|
|
26
|
+
connect() {
|
|
27
|
+
return Promise.resolve();
|
|
28
|
+
}
|
|
29
|
+
query(...args) {
|
|
30
|
+
return Promise.resolve(mock_pgQuery(...args));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
Client,
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
jest.mock("piwik-client", () => {
|
|
39
|
+
class MatomoClient {
|
|
40
|
+
api(...args) {
|
|
41
|
+
return mock_matomoApi(...args);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return MatomoClient;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
jest.resetAllMocks();
|
|
49
|
+
jest.resetModules();
|
|
50
|
+
process.env.STARTDATE = "";
|
|
51
|
+
//process.env.OFFSET = "";
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("run: should create table", async () => {
|
|
55
|
+
mock_pgQuery.mockReturnValue({ rows: [] });
|
|
56
|
+
mock_matomoApi.mockImplementation((options, cb) => {
|
|
57
|
+
return cb(null, []);
|
|
58
|
+
});
|
|
59
|
+
await run();
|
|
60
|
+
expect(mock_pgQuery.mock.calls[0]).toMatchSnapshot();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("run: should fetch the latest event date if no date provided", async () => {
|
|
64
|
+
jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
|
|
65
|
+
mock_pgQuery.mockReturnValue({ rows: [] });
|
|
66
|
+
|
|
67
|
+
mock_matomoApi.mockImplementation((options, cb) => {
|
|
68
|
+
return cb(null, [
|
|
69
|
+
{
|
|
70
|
+
...matomoVisit,
|
|
71
|
+
idVisit: 123,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
...matomoVisit,
|
|
75
|
+
idVisit: 124,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await run();
|
|
81
|
+
|
|
82
|
+
// check matomo requests
|
|
83
|
+
expect(mock_matomoApi.mock.calls[0][0].date).toEqual(isoDate(TEST_DATE));
|
|
84
|
+
expect(mock_matomoApi.mock.calls[0][0].filter_offset).toEqual(0);
|
|
85
|
+
|
|
86
|
+
// check db queries
|
|
87
|
+
expect(mock_pgQuery.mock.calls[3][0]).toEqual(
|
|
88
|
+
// call 0 is create table
|
|
89
|
+
// call 1 is add column usercustomdimension
|
|
90
|
+
// call 2 is add column action_url
|
|
91
|
+
//
|
|
92
|
+
"select action_timestamp from matomo order by action_timestamp desc limit 1"
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("run: should resume using latest event date - offset if no date provided", async () => {
|
|
97
|
+
jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
|
|
98
|
+
|
|
99
|
+
const LAST_EVENT_DATE_OFFSET = 2;
|
|
100
|
+
// @ts-ignore
|
|
101
|
+
const LAST_EVENT_DATE = addDays(TEST_DATE, -LAST_EVENT_DATE_OFFSET);
|
|
102
|
+
|
|
103
|
+
mock_pgQuery.mockReturnValue({ rows: [{ action_timestamp: LAST_EVENT_DATE.getTime() }] });
|
|
104
|
+
|
|
105
|
+
mock_matomoApi.mockImplementation((options, cb) => {
|
|
106
|
+
return cb(null, [
|
|
107
|
+
{
|
|
108
|
+
...matomoVisit,
|
|
109
|
+
idVisit: 123,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
...matomoVisit,
|
|
113
|
+
idVisit: 124,
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await run();
|
|
119
|
+
|
|
120
|
+
// check matomo requests
|
|
121
|
+
expect(mock_matomoApi.mock.calls[0][0].date).toEqual(
|
|
122
|
+
// @ts-ignore
|
|
123
|
+
isoDate(addDays(LAST_EVENT_DATE, -parseInt(OFFSET)))
|
|
124
|
+
);
|
|
125
|
+
expect(mock_matomoApi.mock.calls[0][0].filter_offset).toEqual(0);
|
|
126
|
+
|
|
127
|
+
const daysCount = LAST_EVENT_DATE_OFFSET + parseInt(OFFSET) + 1;
|
|
128
|
+
expect(mock_matomoApi.mock.calls.length).toEqual(daysCount);
|
|
129
|
+
|
|
130
|
+
// check db queries
|
|
131
|
+
expect(mock_pgQuery.mock.calls.length).toEqual(4 + daysCount * 5); // create + alter + alter + select queries + days offset
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("run: should use today date if nothing in DB", async () => {
|
|
135
|
+
jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
|
|
136
|
+
mock_pgQuery.mockReturnValue({ rows: [] });
|
|
137
|
+
|
|
138
|
+
mock_matomoApi.mockImplementation((options, cb) => {
|
|
139
|
+
return cb(null, [
|
|
140
|
+
{
|
|
141
|
+
...matomoVisit,
|
|
142
|
+
idVisit: 123,
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await run();
|
|
148
|
+
|
|
149
|
+
// check matomo requests
|
|
150
|
+
expect(mock_matomoApi.mock.calls.length).toEqual(1);
|
|
151
|
+
console.log(TEST_DATE, isoDate(TEST_DATE));
|
|
152
|
+
expect(mock_matomoApi.mock.calls[0][0].date).toEqual(isoDate(TEST_DATE));
|
|
153
|
+
|
|
154
|
+
// check the 4 events inserted
|
|
155
|
+
expect(mock_pgQuery.mock.calls.length).toEqual(4 + 3); // create, alter, alter, check date, latest + 2 inserts
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("run: should use given date if any", async () => {
|
|
159
|
+
jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
|
|
160
|
+
mock_pgQuery.mockReturnValue({ rows: [] });
|
|
161
|
+
|
|
162
|
+
mock_matomoApi.mockImplementation((options, cb) => {
|
|
163
|
+
return cb(null, [
|
|
164
|
+
{
|
|
165
|
+
...matomoVisit,
|
|
166
|
+
idVisit: 123,
|
|
167
|
+
},
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// @ts-ignore
|
|
172
|
+
await run(isoDate(addDays(TEST_DATE, -10)) + "T00:00:00.000Z");
|
|
173
|
+
|
|
174
|
+
expect(mock_matomoApi.mock.calls.length).toEqual(11);
|
|
175
|
+
expect(mock_pgQuery.mock.calls.length).toEqual(3 + 11 * 3); // create table + alter + alter + inserts. no initial select as date is provided
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("run: should use STARTDATE if any", async () => {
|
|
179
|
+
// @ts-ignore
|
|
180
|
+
process.env.STARTDATE = isoDate(addDays(TEST_DATE, -5)) + "T00:00:00.000Z";
|
|
181
|
+
jest.useFakeTimers("modern").setSystemTime(TEST_DATE.getTime());
|
|
182
|
+
mock_pgQuery.mockReturnValue({ rows: [] });
|
|
183
|
+
|
|
184
|
+
mock_matomoApi.mockImplementation((options, cb) => {
|
|
185
|
+
return cb(null, [
|
|
186
|
+
{
|
|
187
|
+
...matomoVisit,
|
|
188
|
+
idVisit: 123,
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await run();
|
|
194
|
+
|
|
195
|
+
expect(mock_matomoApi.mock.calls.length).toEqual(6);
|
|
196
|
+
|
|
197
|
+
expect(mock_pgQuery.mock.calls.length).toEqual(4 + 6 * 3); // create table + alter + alter + initial select + inserts.
|
|
198
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"idVisit": "124",
|
|
3
|
+
"idSite": "42",
|
|
4
|
+
"country": "Argentine",
|
|
5
|
+
"operatingSystemName": "Mac",
|
|
6
|
+
"deviceBrand": "Inconnu",
|
|
7
|
+
"deviceModel": "Générique Bureau",
|
|
8
|
+
"visitDuration": "300",
|
|
9
|
+
"daysSinceFirstVisit": "23",
|
|
10
|
+
"actions": "2",
|
|
11
|
+
"visitorType": "returningCustomer",
|
|
12
|
+
"siteName": "tests",
|
|
13
|
+
"userId": "24",
|
|
14
|
+
"region": "Buenos Aires",
|
|
15
|
+
"city": "Buenos Aires",
|
|
16
|
+
"dimension1": "guest",
|
|
17
|
+
"dimension3": "page",
|
|
18
|
+
"firstActionTimestamp": 1629496512,
|
|
19
|
+
"actionDetails": [
|
|
20
|
+
{
|
|
21
|
+
"type": "event",
|
|
22
|
+
"url": "https://dive-shop.net/products/basic-wetsuit/",
|
|
23
|
+
"pageIdAction": "304",
|
|
24
|
+
"idpageview": "",
|
|
25
|
+
"serverTimePretty": "20 août 2021 21:35:18",
|
|
26
|
+
"pageId": "19696671",
|
|
27
|
+
"eventCategory": "Ecommerce",
|
|
28
|
+
"eventAction": "Cart change",
|
|
29
|
+
"timeSpent": 48,
|
|
30
|
+
"timeSpentPretty": "48s",
|
|
31
|
+
"pageviewPosition": "8",
|
|
32
|
+
"timestamp": 1629495318,
|
|
33
|
+
"icon": "plugins/Morpheus/images/event.png",
|
|
34
|
+
"iconSVG": "plugins/Morpheus/images/event.svg",
|
|
35
|
+
"title": "Evènement",
|
|
36
|
+
"subtitle": "Catégorie: \"Ecommerce', Action: \"Cart change\"",
|
|
37
|
+
"eventName": "added - Basic Wetsuit",
|
|
38
|
+
"eventValue": 1,
|
|
39
|
+
"dimension2": "julien",
|
|
40
|
+
"dimension4": "indonesia",
|
|
41
|
+
"dimension5": "diving",
|
|
42
|
+
"customVariables": {
|
|
43
|
+
"1": {
|
|
44
|
+
"customVariableName1": "page-author",
|
|
45
|
+
"customVariableValue1": "Julien"
|
|
46
|
+
},
|
|
47
|
+
"2": {
|
|
48
|
+
"customVariableName2": "post-age",
|
|
49
|
+
"customVariableValue2": "-430 days"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "action",
|
|
55
|
+
"url": "https://dive-shop.net/products/diving-boots/",
|
|
56
|
+
"pageTitle": "Divezone Brand Diving Boots - Divezone Store",
|
|
57
|
+
"pageIdAction": "60",
|
|
58
|
+
"idpageview": "8CDIez",
|
|
59
|
+
"serverTimePretty": "20 août 2021 21:30:25",
|
|
60
|
+
"pageId": "19696664",
|
|
61
|
+
"timeSpent": "2",
|
|
62
|
+
"timeSpentPretty": "2s",
|
|
63
|
+
"pageviewPosition": "5",
|
|
64
|
+
"title": "Divezone Brand Diving Boots - Divezone Store",
|
|
65
|
+
"subtitle": "https://dive-shop.net/products/diving-boots/",
|
|
66
|
+
"icon": "",
|
|
67
|
+
"iconSVG": "plugins/Morpheus/images/action.svg",
|
|
68
|
+
"timestamp": 1629495025,
|
|
69
|
+
"dimension2": "julien",
|
|
70
|
+
"dimension4": "indonesia",
|
|
71
|
+
"dimension5": "diving"
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const MATOMO_KEY = process.env.MATOMO_KEY || "";
|
|
2
|
+
const MATOMO_URL = process.env.MATOMO_URL || "https://matomo.fabrique.social.gouv.fr/";
|
|
3
|
+
const MATOMO_SITE = process.env.MATOMO_SITE || 0;
|
|
4
|
+
const PGDATABASE = process.env.PGDATABASE || "";
|
|
5
|
+
const DESTINATION_TABLE = process.env.DESTINATION_TABLE || "matomo";
|
|
6
|
+
const OFFSET = process.env.OFFSET || "3";
|
|
7
|
+
const RESULTPERPAGE = process.env.RESULTPERPAGE || "500";
|
|
8
|
+
|
|
9
|
+
module.exports = { MATOMO_KEY, MATOMO_URL, MATOMO_SITE, PGDATABASE, DESTINATION_TABLE, OFFSET, RESULTPERPAGE };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { Client } = require("pg");
|
|
2
|
+
|
|
3
|
+
const { DESTINATION_TABLE } = require("./config");
|
|
4
|
+
/**
|
|
5
|
+
*
|
|
6
|
+
* @param {Client} client
|
|
7
|
+
*/
|
|
8
|
+
async function createTable(client) {
|
|
9
|
+
const text = `CREATE TABLE IF NOT EXISTS ${client.escapeIdentifier(DESTINATION_TABLE)}
|
|
10
|
+
(
|
|
11
|
+
idsite text,
|
|
12
|
+
idvisit text,
|
|
13
|
+
actions text,
|
|
14
|
+
country text,
|
|
15
|
+
region text,
|
|
16
|
+
city text,
|
|
17
|
+
operatingsystemname text,
|
|
18
|
+
devicemodel text,
|
|
19
|
+
devicebrand text,
|
|
20
|
+
visitduration text,
|
|
21
|
+
dayssincefirstvisit text,
|
|
22
|
+
visitortype text,
|
|
23
|
+
sitename text,
|
|
24
|
+
userid text,
|
|
25
|
+
serverdateprettyfirstaction date,
|
|
26
|
+
action_id text UNIQUE,
|
|
27
|
+
action_type text,
|
|
28
|
+
action_eventcategory text,
|
|
29
|
+
action_eventaction text,
|
|
30
|
+
action_eventname text,
|
|
31
|
+
action_eventvalue text,
|
|
32
|
+
action_timespent text,
|
|
33
|
+
action_timestamp timestamp with time zone,
|
|
34
|
+
usercustomproperties json,
|
|
35
|
+
usercustomdimensions json,
|
|
36
|
+
action_url text
|
|
37
|
+
)`;
|
|
38
|
+
await client.query(text, []);
|
|
39
|
+
const addUserCustomDimensionColumn = `ALTER TABLE IF EXISTS ${client.escapeIdentifier(DESTINATION_TABLE)}
|
|
40
|
+
ADD COLUMN IF NOT EXISTS "usercustomdimensions" json;`;
|
|
41
|
+
await client.query(addUserCustomDimensionColumn, []);
|
|
42
|
+
|
|
43
|
+
const addActionUrlColumn = `ALTER TABLE IF EXISTS ${client.escapeIdentifier(DESTINATION_TABLE)}
|
|
44
|
+
ADD COLUMN IF NOT EXISTS "action_url" text;`;
|
|
45
|
+
await client.query(addActionUrlColumn, []);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { createTable };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const { Client } = require("pg");
|
|
2
|
+
const pAll = require("p-all");
|
|
3
|
+
const debug = require("debug")("importDate");
|
|
4
|
+
const formatISO = require("date-fns/formatISO");
|
|
5
|
+
|
|
6
|
+
const { importEvent, getEventsFromMatomoVisit } = require("./importEvent");
|
|
7
|
+
|
|
8
|
+
const { RESULTPERPAGE, MATOMO_SITE, DESTINATION_TABLE } = require("./config");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* return date as ISO yyyy-mm-dd
|
|
12
|
+
*
|
|
13
|
+
* @param {Date} date date
|
|
14
|
+
*
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
// @ts-ignore
|
|
18
|
+
const isoDate = (date) => formatISO(date, { representation: "date" });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* check count if imported rows for given date
|
|
22
|
+
*
|
|
23
|
+
* @param {Client} client PG client
|
|
24
|
+
* @param {Date} date datetime as ISO string
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<any>}
|
|
27
|
+
*/
|
|
28
|
+
const getRecordsCount = async (client, date) => {
|
|
29
|
+
const text = `SELECT COUNT(distinct idvisit) FROM ${client.escapeIdentifier(
|
|
30
|
+
DESTINATION_TABLE
|
|
31
|
+
)} WHERE action_timestamp::date='${isoDate(date)}';`;
|
|
32
|
+
const result = await client.query(text);
|
|
33
|
+
return parseInt((result && result.rows && result.rows.length && result.rows[0].count) || 0);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* import all matomo data for a given date
|
|
38
|
+
*
|
|
39
|
+
* @param {Client} client PG client
|
|
40
|
+
* @param {any} piwikApi piwik.api instance
|
|
41
|
+
* @param {Date} date datetime as ISO string
|
|
42
|
+
*
|
|
43
|
+
* @returns {Promise<any[]>}
|
|
44
|
+
*/
|
|
45
|
+
const importDate = async (client, piwikApi, date, filterOffset = 0) => {
|
|
46
|
+
const limit = parseInt(RESULTPERPAGE);
|
|
47
|
+
const offset = filterOffset || (await getRecordsCount(client, date));
|
|
48
|
+
if (!offset) {
|
|
49
|
+
debug(`${isoDate(date)}: load ${limit} visits`);
|
|
50
|
+
} else {
|
|
51
|
+
debug(`${isoDate(date)}: load ${limit} more visits after ${offset}`);
|
|
52
|
+
}
|
|
53
|
+
// fetch visits details
|
|
54
|
+
const visits = await new Promise((resolve) =>
|
|
55
|
+
piwikApi(
|
|
56
|
+
{
|
|
57
|
+
method: "Live.getLastVisitsDetails",
|
|
58
|
+
period: "day",
|
|
59
|
+
date: isoDate(date),
|
|
60
|
+
filter_limit: limit,
|
|
61
|
+
filter_offset: offset,
|
|
62
|
+
filter_sort_order: "asc",
|
|
63
|
+
idSite: MATOMO_SITE,
|
|
64
|
+
},
|
|
65
|
+
(err, responseObject = []) => {
|
|
66
|
+
if (err) {
|
|
67
|
+
console.error("err", err);
|
|
68
|
+
resolve([]);
|
|
69
|
+
}
|
|
70
|
+
return resolve(responseObject || []);
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
debug(`fetched ${visits.length} visits`);
|
|
76
|
+
|
|
77
|
+
// flatten events
|
|
78
|
+
const allEvents = visits.flatMap(getEventsFromMatomoVisit);
|
|
79
|
+
|
|
80
|
+
if (!allEvents.length) {
|
|
81
|
+
debug(`no more valid events after ${isoDate(date)}`);
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
debug(`import ${allEvents.length} events`);
|
|
86
|
+
|
|
87
|
+
// serial-import events into PG
|
|
88
|
+
const importedEvents = await pAll(
|
|
89
|
+
allEvents.map((event) => () => importEvent(client, event)),
|
|
90
|
+
{ concurrency: 10, stopOnError: true }
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// continue to next page if necessary
|
|
94
|
+
if (visits.length === limit) {
|
|
95
|
+
const nextOffset = offset + limit;
|
|
96
|
+
const nextEvents = await importDate(client, piwikApi, date, nextOffset);
|
|
97
|
+
return [...importedEvents, ...(nextEvents || [])];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
debug(`finished importing ${isoDate(date)}, offset ${offset}`);
|
|
101
|
+
|
|
102
|
+
return importedEvents || [];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
module.exports = { importDate };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const { Client } = require("pg");
|
|
2
|
+
|
|
3
|
+
const { DESTINATION_TABLE } = require("./config");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
*
|
|
7
|
+
* @param {Client} client
|
|
8
|
+
* @param {import("types").Event} event
|
|
9
|
+
*
|
|
10
|
+
* @return {Promise<Record<"rows", any[]>>}
|
|
11
|
+
*/
|
|
12
|
+
const importEvent = (client, event) => {
|
|
13
|
+
const eventKeys = Object.keys(event);
|
|
14
|
+
const text = `insert into ${client.escapeIdentifier(DESTINATION_TABLE)}
|
|
15
|
+
(${eventKeys.join(", ")})
|
|
16
|
+
values (${eventKeys.map((k, i) => `\$${i + 1}`).join(", ")})
|
|
17
|
+
ON CONFLICT DO NOTHING`;
|
|
18
|
+
const values = [...eventKeys.map((k) => event[k])];
|
|
19
|
+
return client.query(text, values).catch((e) => {
|
|
20
|
+
console.log("QUERY error", e);
|
|
21
|
+
return { rows: [] };
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const matomoProps = [
|
|
26
|
+
"idSite",
|
|
27
|
+
"idVisit",
|
|
28
|
+
"actions",
|
|
29
|
+
"country",
|
|
30
|
+
"region",
|
|
31
|
+
"city",
|
|
32
|
+
"operatingSystemName",
|
|
33
|
+
"deviceModel",
|
|
34
|
+
"deviceBrand",
|
|
35
|
+
"visitDuration",
|
|
36
|
+
"daysSinceFirstVisit",
|
|
37
|
+
"visitorType",
|
|
38
|
+
"siteName",
|
|
39
|
+
"userId",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** @type Record<string, (a: import("types/matomo").ActionDetail) => string | number> */
|
|
43
|
+
const actionProps = {
|
|
44
|
+
action_type: (action) => action.type,
|
|
45
|
+
action_eventcategory: (action) => action.eventCategory,
|
|
46
|
+
action_eventaction: (action) => action.eventAction,
|
|
47
|
+
action_eventname: (action) => action.eventName,
|
|
48
|
+
action_eventvalue: (action) => action.eventValue,
|
|
49
|
+
action_timespent: (action) => action.timeSpent,
|
|
50
|
+
action_timestamp: (action) => new Date(action.timestamp * 1000).toISOString(),
|
|
51
|
+
action_url: (action) => action.url,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Convert a single matomo visit {<import("types/matomo").Visit} to multiple {Event[]}
|
|
56
|
+
*
|
|
57
|
+
* @param {Partial<import("types/matomo").Visit>} matomoVisit
|
|
58
|
+
*
|
|
59
|
+
* @return {import("types").Event[]} list of events
|
|
60
|
+
*
|
|
61
|
+
*/
|
|
62
|
+
const getEventsFromMatomoVisit = (matomoVisit) => {
|
|
63
|
+
return matomoVisit.actionDetails.map((actionDetail, actionIndex) => {
|
|
64
|
+
/** @type {Record<string, string>} */
|
|
65
|
+
const usercustomproperties = {};
|
|
66
|
+
for (let k = 1; k < 10; k++) {
|
|
67
|
+
const property = actionDetail.customVariables && actionDetail.customVariables[k];
|
|
68
|
+
if (!property) continue; // max 10 custom variables
|
|
69
|
+
usercustomproperties[property[`customVariableName${k}`]] = property[`customVariableValue${k}`];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @type {Record<string, string>} */
|
|
73
|
+
const usercustomdimensions = {};
|
|
74
|
+
for (let k = 1; k < 10; k++) {
|
|
75
|
+
const dimension = `dimension${k}`;
|
|
76
|
+
const value = actionDetail[dimension] || matomoVisit[dimension];
|
|
77
|
+
if (!value) continue; // max 10 custom variables
|
|
78
|
+
usercustomdimensions[dimension] = value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @type {import("types").Event} */
|
|
82
|
+
// @ts-ignore
|
|
83
|
+
const event = {
|
|
84
|
+
// default matomo visit properties
|
|
85
|
+
...matomoProps.reduce((a, prop) => ({ ...a, [prop.toLowerCase()]: matomoVisit[prop] }), {}),
|
|
86
|
+
serverdateprettyfirstaction: new Date((matomoVisit.firstActionTimestamp || 0) * 1000).toISOString(),
|
|
87
|
+
// action specific properties
|
|
88
|
+
...Object.keys(actionProps).reduce((a, prop) => ({ ...a, [prop]: actionProps[prop](actionDetail) }), {
|
|
89
|
+
action_id: `${matomoVisit.idVisit}_${actionIndex}`,
|
|
90
|
+
}),
|
|
91
|
+
// custom variables
|
|
92
|
+
usercustomproperties,
|
|
93
|
+
usercustomdimensions,
|
|
94
|
+
};
|
|
95
|
+
return event;
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
module.exports = { getEventsFromMatomoVisit, importEvent };
|