@socialgouv/matomo-postgres 2.3.11 → 2.3.13
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/dist/PiwikClient.js +95 -0
- package/dist/__tests__/importDate.test.js +134 -0
- package/dist/__tests__/importEvent.test.js +9 -0
- package/dist/__tests__/migration-imports.test.js +38 -0
- package/dist/__tests__/run.test.js +95 -0
- package/dist/__tests__/visit.json +104 -0
- package/dist/config.js +12 -0
- package/dist/db.js +31 -0
- package/dist/importDate.js +101 -0
- package/dist/importEvent.js +214 -0
- package/dist/index.js +107 -0
- package/dist/migrate-down.js +41 -0
- package/dist/migrate-latest.js +67 -0
- package/dist/migrations/20230301-01-initial.js +65 -0
- package/dist/migrations/20230301-02-indexes.js +100 -0
- package/dist/migrations/20250425-01-add-resolution.js +38 -0
- package/dist/migrations/20250715-01-weekly-partitioning.js +364 -0
- package/dist/migrations/20250908-01-convention-analysis-index.js +29 -0
- package/package.json +3 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import * as https from 'https';
|
|
3
|
+
import * as querystring from 'querystring';
|
|
4
|
+
import * as url from 'url';
|
|
5
|
+
export default class PiwikClient {
|
|
6
|
+
constructor(baseURL, token) {
|
|
7
|
+
const parsedUrl = url.parse(baseURL, true);
|
|
8
|
+
this.settings = {
|
|
9
|
+
apihost: parsedUrl.hostname || '',
|
|
10
|
+
apipath: parsedUrl.pathname || ''
|
|
11
|
+
};
|
|
12
|
+
// Determine protocol and set http module
|
|
13
|
+
switch (parsedUrl.protocol) {
|
|
14
|
+
case 'http:':
|
|
15
|
+
this.http = http;
|
|
16
|
+
this.settings.apiport = parsedUrl.port
|
|
17
|
+
? parseInt(parsedUrl.port, 10)
|
|
18
|
+
: 80;
|
|
19
|
+
break;
|
|
20
|
+
case 'https:':
|
|
21
|
+
this.http = https;
|
|
22
|
+
this.settings.apiport = parsedUrl.port
|
|
23
|
+
? parseInt(parsedUrl.port, 10)
|
|
24
|
+
: 443;
|
|
25
|
+
break;
|
|
26
|
+
default:
|
|
27
|
+
this.http = http;
|
|
28
|
+
this.settings.apiport = 80;
|
|
29
|
+
}
|
|
30
|
+
// Set token from URL query or constructor parameter
|
|
31
|
+
if (parsedUrl.query && parsedUrl.query.token_auth) {
|
|
32
|
+
this.settings.token = parsedUrl.query.token_auth;
|
|
33
|
+
}
|
|
34
|
+
if (token) {
|
|
35
|
+
this.settings.token = token;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
api(vars, cb) {
|
|
39
|
+
if (typeof vars !== 'object') {
|
|
40
|
+
vars = {};
|
|
41
|
+
}
|
|
42
|
+
// Set default values
|
|
43
|
+
vars.module = 'API';
|
|
44
|
+
vars.format = 'JSON';
|
|
45
|
+
// Set token if not provided in vars
|
|
46
|
+
if (vars.token_auth == null) {
|
|
47
|
+
vars.token_auth = this.settings.token;
|
|
48
|
+
}
|
|
49
|
+
// Extract token_auth for POST body
|
|
50
|
+
const token_auth = vars.token_auth;
|
|
51
|
+
const postData = querystring.stringify({ token_auth });
|
|
52
|
+
// Remove token_auth from URL query params
|
|
53
|
+
const queryVars = Object.assign({}, vars);
|
|
54
|
+
delete queryVars.token_auth;
|
|
55
|
+
// Prepare request options
|
|
56
|
+
const options = {
|
|
57
|
+
host: this.settings.apihost,
|
|
58
|
+
port: this.settings.apiport,
|
|
59
|
+
path: this.settings.apipath + '?' + querystring.stringify(queryVars),
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
// Make HTTP POST request
|
|
66
|
+
const req = this.http.request(options, (response) => {
|
|
67
|
+
let data = '';
|
|
68
|
+
// Collect data chunks
|
|
69
|
+
response.on('data', (chunk) => {
|
|
70
|
+
data += chunk;
|
|
71
|
+
});
|
|
72
|
+
// Process complete response
|
|
73
|
+
response.on('end', () => {
|
|
74
|
+
try {
|
|
75
|
+
const resObj = JSON.parse(data);
|
|
76
|
+
if (resObj.result === 'error') {
|
|
77
|
+
return cb(new Error(resObj.message), null);
|
|
78
|
+
}
|
|
79
|
+
return cb(null, resObj);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return cb(error instanceof Error ? error : new Error(String(error)), null);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
// Handle request errors
|
|
87
|
+
req.on('error', (error) => {
|
|
88
|
+
cb(error, null);
|
|
89
|
+
});
|
|
90
|
+
// Write POST data and end request
|
|
91
|
+
req.write(postData);
|
|
92
|
+
req.end();
|
|
93
|
+
return req;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
// Set environment variables BEFORE any imports
|
|
11
|
+
process.env.MATOMO_SITE = '42';
|
|
12
|
+
process.env.PROJECT_NAME = 'some-project';
|
|
13
|
+
process.env.RESULTPERPAGE = '10';
|
|
14
|
+
process.env.DESTINATION_TABLE = 'matomo';
|
|
15
|
+
import { Pool } from 'pg';
|
|
16
|
+
import { importDate } from '../importDate';
|
|
17
|
+
import matomoVisit from './visit.json';
|
|
18
|
+
const TEST_DATE = new Date(2023, 3, 15);
|
|
19
|
+
let queries = [];
|
|
20
|
+
const result = {
|
|
21
|
+
command: 'string',
|
|
22
|
+
rowCount: 0
|
|
23
|
+
};
|
|
24
|
+
jest.mock('pg', () => {
|
|
25
|
+
const client = {
|
|
26
|
+
query: (query, values) => {
|
|
27
|
+
queries.push([query, values]);
|
|
28
|
+
return result;
|
|
29
|
+
},
|
|
30
|
+
release: jest.fn()
|
|
31
|
+
};
|
|
32
|
+
const methods = {
|
|
33
|
+
connect: () => client,
|
|
34
|
+
on: jest.fn(),
|
|
35
|
+
query: jest.fn()
|
|
36
|
+
};
|
|
37
|
+
return { Pool: jest.fn(() => methods) };
|
|
38
|
+
});
|
|
39
|
+
let pool;
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
pool = new Pool();
|
|
42
|
+
queries = [];
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
jest.clearAllMocks();
|
|
46
|
+
});
|
|
47
|
+
test('importDate: should import given date', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
48
|
+
const piwikApi = jest.fn();
|
|
49
|
+
piwikApi.mockImplementation((options, cb) => {
|
|
50
|
+
cb(null, [
|
|
51
|
+
Object.assign(Object.assign({}, matomoVisit), { idVisit: 123 }),
|
|
52
|
+
Object.assign(Object.assign({}, matomoVisit), { idVisit: 124 })
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
pool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
|
56
|
+
const result = yield importDate(piwikApi, TEST_DATE);
|
|
57
|
+
expect(piwikApi.mock.calls.length).toEqual(1);
|
|
58
|
+
expect(piwikApi.mock.calls[0][0]).toMatchInlineSnapshot(`
|
|
59
|
+
{
|
|
60
|
+
"date": "2023-04-15",
|
|
61
|
+
"filter_limit": 10,
|
|
62
|
+
"filter_offset": 0,
|
|
63
|
+
"filter_sort_order": "asc",
|
|
64
|
+
"idSite": "42",
|
|
65
|
+
"method": "Live.getLastVisitsDetails",
|
|
66
|
+
"period": "day",
|
|
67
|
+
}
|
|
68
|
+
`);
|
|
69
|
+
expect(queries[0]).toMatchInlineSnapshot(`
|
|
70
|
+
[
|
|
71
|
+
"select count(distinct "idvisit") as "count" from "matomo" where date(timezone('UTC', action_timestamp)) = $1",
|
|
72
|
+
[
|
|
73
|
+
"2023-04-15",
|
|
74
|
+
],
|
|
75
|
+
]
|
|
76
|
+
`);
|
|
77
|
+
expect(queries.length).toEqual(1 + matomoVisit.actionDetails.length * 2);
|
|
78
|
+
expect(result).toMatchObject({
|
|
79
|
+
date: '2023-04-15',
|
|
80
|
+
pages: 1,
|
|
81
|
+
visitsFetched: 2,
|
|
82
|
+
eventsImported: matomoVisit.actionDetails.length * 2
|
|
83
|
+
});
|
|
84
|
+
}));
|
|
85
|
+
test('importDate: should handle pagination across multiple pages', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
86
|
+
const piwikApi = jest.fn();
|
|
87
|
+
// Mock first call to return exactly 10 visits (triggers pagination)
|
|
88
|
+
piwikApi
|
|
89
|
+
.mockImplementationOnce((options, cb) => {
|
|
90
|
+
const visits = Array.from({ length: 10 }, (_, i) => (Object.assign(Object.assign({}, matomoVisit), { idVisit: 200 + i })));
|
|
91
|
+
cb(null, visits);
|
|
92
|
+
})
|
|
93
|
+
// Mock second call to return 5 visits (stops pagination)
|
|
94
|
+
.mockImplementationOnce((options, cb) => {
|
|
95
|
+
const visits = Array.from({ length: 5 }, (_, i) => (Object.assign(Object.assign({}, matomoVisit), { idVisit: 300 + i })));
|
|
96
|
+
cb(null, visits);
|
|
97
|
+
});
|
|
98
|
+
// Mock database query for record count
|
|
99
|
+
pool.query.mockResolvedValue({ rows: [], rowCount: 0 });
|
|
100
|
+
const result = yield importDate(piwikApi, TEST_DATE);
|
|
101
|
+
// Should make exactly 2 API calls due to pagination
|
|
102
|
+
expect(piwikApi.mock.calls.length).toEqual(2);
|
|
103
|
+
// First call should have offset 0
|
|
104
|
+
expect(piwikApi.mock.calls[0][0]).toMatchObject({
|
|
105
|
+
date: '2023-04-15',
|
|
106
|
+
filter_limit: 10,
|
|
107
|
+
filter_offset: 0,
|
|
108
|
+
filter_sort_order: 'asc',
|
|
109
|
+
idSite: '42',
|
|
110
|
+
method: 'Live.getLastVisitsDetails',
|
|
111
|
+
period: 'day'
|
|
112
|
+
});
|
|
113
|
+
// Second call should have offset 10
|
|
114
|
+
expect(piwikApi.mock.calls[1][0]).toMatchObject({
|
|
115
|
+
date: '2023-04-15',
|
|
116
|
+
filter_limit: 10,
|
|
117
|
+
filter_offset: 10,
|
|
118
|
+
filter_sort_order: 'asc',
|
|
119
|
+
idSite: '42',
|
|
120
|
+
method: 'Live.getLastVisitsDetails',
|
|
121
|
+
period: 'day'
|
|
122
|
+
});
|
|
123
|
+
// Should process all events from both pages
|
|
124
|
+
// 15 visits total × 3 actionDetails each = 45 events
|
|
125
|
+
expect(result).toMatchObject({
|
|
126
|
+
date: '2023-04-15',
|
|
127
|
+
pages: 2,
|
|
128
|
+
visitsFetched: 15,
|
|
129
|
+
eventsImported: 45
|
|
130
|
+
});
|
|
131
|
+
// Verify database queries: 1 count query + (45 events × 1 query per event)
|
|
132
|
+
// Note: Each event generates 1 database query for insertion
|
|
133
|
+
expect(queries.length).toEqual(1 + 45);
|
|
134
|
+
}));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getEventsFromMatomoVisit } from '../importEvent.js';
|
|
2
|
+
import matomoVisit from './visit.json';
|
|
3
|
+
process.env.MATOMO_SITE = '42';
|
|
4
|
+
process.env.PROJECT_NAME = 'some-project';
|
|
5
|
+
process.env.RESULTPERPAGE = '10';
|
|
6
|
+
test('getEventsFromMatomoVisit: should merge action events', () => {
|
|
7
|
+
const visits = getEventsFromMatomoVisit(matomoVisit);
|
|
8
|
+
expect(visits).toMatchSnapshot();
|
|
9
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { readdir, readFile } from 'fs/promises';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
describe('Migration Import Validation', () => {
|
|
13
|
+
it('should not have src/ imports in migration files', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
14
|
+
const migrationsDir = join(__dirname, '../migrations');
|
|
15
|
+
const migrationFiles = yield readdir(migrationsDir);
|
|
16
|
+
const tsFiles = migrationFiles.filter((file) => file.endsWith('.ts'));
|
|
17
|
+
const problematicFiles = [];
|
|
18
|
+
for (const file of tsFiles) {
|
|
19
|
+
const filePath = join(migrationsDir, file);
|
|
20
|
+
const content = yield readFile(filePath, 'utf-8');
|
|
21
|
+
// Check for imports from 'src/' paths
|
|
22
|
+
const srcImportRegex = /(?:import\s+[^;]+from\s+['"]src\/|require\s*\(\s*['"]src\/)/g;
|
|
23
|
+
const matches = content.match(srcImportRegex);
|
|
24
|
+
if (matches) {
|
|
25
|
+
problematicFiles.push(`${file}: ${matches.join(', ')}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (problematicFiles.length > 0) {
|
|
29
|
+
fail(`Migration files contain problematic src/ imports that will fail in production:\n${problematicFiles.join('\n')}\n\nMigration files should use direct constants or relative imports instead.`);
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
it('should have at least one migration file', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
33
|
+
const migrationsDir = join(__dirname, '../migrations');
|
|
34
|
+
const migrationFiles = yield readdir(migrationsDir);
|
|
35
|
+
const tsFiles = migrationFiles.filter((file) => file.endsWith('.ts'));
|
|
36
|
+
expect(tsFiles.length).toBeGreaterThan(0);
|
|
37
|
+
}));
|
|
38
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
process.env.MATOMO_SITE = '42';
|
|
11
|
+
process.env.PROJECT_NAME = 'some-project';
|
|
12
|
+
process.env.RESULTPERPAGE = '10';
|
|
13
|
+
delete process.env.INITIAL_OFFSET;
|
|
14
|
+
delete process.env.DESTINATION_TABLE;
|
|
15
|
+
delete process.env.STARTDATE;
|
|
16
|
+
// Clear STARTDATE to avoid conflicts with fake timers
|
|
17
|
+
const TEST_DATE = new Date(2023, 3, 1);
|
|
18
|
+
let queries = [];
|
|
19
|
+
let piwikApiCalls = [];
|
|
20
|
+
const result = {
|
|
21
|
+
command: 'string',
|
|
22
|
+
rowCount: 0
|
|
23
|
+
};
|
|
24
|
+
jest.mock('pg', () => {
|
|
25
|
+
const client = {
|
|
26
|
+
query: (query, values) => {
|
|
27
|
+
queries.push([query, values]);
|
|
28
|
+
return result;
|
|
29
|
+
},
|
|
30
|
+
release: jest.fn()
|
|
31
|
+
};
|
|
32
|
+
const methods = {
|
|
33
|
+
connect: () => client,
|
|
34
|
+
on: jest.fn(),
|
|
35
|
+
query: jest.fn()
|
|
36
|
+
};
|
|
37
|
+
return { Pool: jest.fn(() => methods) };
|
|
38
|
+
});
|
|
39
|
+
jest.mock('../PiwikClient', () => {
|
|
40
|
+
class PiwikMock {
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
}
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
45
|
+
api(options, cb) {
|
|
46
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
47
|
+
// Import the visit data dynamically to avoid circular dependency
|
|
48
|
+
const { default: matomoVisit } = yield import('./visit.json');
|
|
49
|
+
const matomoVisits = [
|
|
50
|
+
Object.assign(Object.assign({}, matomoVisit), { idVisit: 123 }),
|
|
51
|
+
Object.assign(Object.assign({}, matomoVisit), { idVisit: 124 })
|
|
52
|
+
];
|
|
53
|
+
piwikApiCalls.push(options);
|
|
54
|
+
cb(null, matomoVisits);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return PiwikMock;
|
|
59
|
+
});
|
|
60
|
+
// Import after mocks are set up
|
|
61
|
+
import run from '../index';
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
queries = [];
|
|
64
|
+
piwikApiCalls = [];
|
|
65
|
+
});
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
jest.clearAllMocks();
|
|
68
|
+
});
|
|
69
|
+
test('run: should fetch the latest 5 days on matomo', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
70
|
+
jest.useFakeTimers().setSystemTime(TEST_DATE.getTime());
|
|
71
|
+
yield run();
|
|
72
|
+
expect(piwikApiCalls).toMatchSnapshot();
|
|
73
|
+
}));
|
|
74
|
+
test('run: should fetch the latest event date if no date provided', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
75
|
+
jest.useFakeTimers().setSystemTime(TEST_DATE.getTime());
|
|
76
|
+
yield run();
|
|
77
|
+
expect(queries[0]).toMatchSnapshot();
|
|
78
|
+
}));
|
|
79
|
+
test('run: should run based on existing data if any', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
80
|
+
// ensure we use the latest entry in DB
|
|
81
|
+
expect(1).toEqual(1);
|
|
82
|
+
}));
|
|
83
|
+
test('run: should run SQL queries', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
84
|
+
jest.useFakeTimers().setSystemTime(TEST_DATE.getTime());
|
|
85
|
+
const result = yield run();
|
|
86
|
+
expect(queries).toMatchSnapshot();
|
|
87
|
+
// Updated expectation based on actual behavior with INITIAL_OFFSET=3 (5 days total: 3 days before + today + 1 day after)
|
|
88
|
+
// 5 days * (6 events per day + 1 count query per day)
|
|
89
|
+
// Note: We also capture the initial "findLastEventInMatomo" query.
|
|
90
|
+
expect(queries.length).toEqual(1 + 5 * (6 + 1));
|
|
91
|
+
expect(result).toMatchObject({
|
|
92
|
+
daysProcessed: 5,
|
|
93
|
+
eventsImportedTotal: 5 * 6
|
|
94
|
+
});
|
|
95
|
+
}));
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
"visitorId": "visitorId",
|
|
13
|
+
"referrerType": "referrerType",
|
|
14
|
+
"referrerName": "referrerName",
|
|
15
|
+
"siteName": "tests",
|
|
16
|
+
"userId": "24",
|
|
17
|
+
"region": "Buenos Aires",
|
|
18
|
+
"city": "Buenos Aires",
|
|
19
|
+
"resolution": "1920x1080",
|
|
20
|
+
"dimension1": "guest",
|
|
21
|
+
"dimension3": "page",
|
|
22
|
+
"dimension6": "shop",
|
|
23
|
+
"dimension7": "v1.2.3",
|
|
24
|
+
"dimension8": "fr",
|
|
25
|
+
"dimension9": "light",
|
|
26
|
+
"dimension10": "36",
|
|
27
|
+
"firstActionTimestamp": 1629496512,
|
|
28
|
+
"actionDetails": [
|
|
29
|
+
{
|
|
30
|
+
"type": "event",
|
|
31
|
+
"url": "https://dive-shop.net/products/basic-wetsuit/",
|
|
32
|
+
"pageIdAction": "304",
|
|
33
|
+
"idpageview": "",
|
|
34
|
+
"serverTimePretty": "20 août 2021 21:35:18",
|
|
35
|
+
"pageId": "19696671",
|
|
36
|
+
"eventCategory": "Ecommerce",
|
|
37
|
+
"eventAction": "Cart change",
|
|
38
|
+
"timeSpent": 48,
|
|
39
|
+
"timeSpentPretty": "48s",
|
|
40
|
+
"pageviewPosition": "8",
|
|
41
|
+
"timestamp": 1629495318,
|
|
42
|
+
"icon": "plugins/Morpheus/images/event.png",
|
|
43
|
+
"iconSVG": "plugins/Morpheus/images/event.svg",
|
|
44
|
+
"title": "Evènement",
|
|
45
|
+
"subtitle": "Catégorie: \"Ecommerce', Action: \"Cart change\"",
|
|
46
|
+
"eventName": "added - Basic Wetsuit",
|
|
47
|
+
"eventValue": 1,
|
|
48
|
+
"dimension2": "julien",
|
|
49
|
+
"dimension4": "indonesia",
|
|
50
|
+
"dimension5": "diving",
|
|
51
|
+
"customVariables": {
|
|
52
|
+
"1": {
|
|
53
|
+
"customVariableName1": "page-author",
|
|
54
|
+
"customVariableValue1": "Julien"
|
|
55
|
+
},
|
|
56
|
+
"2": {
|
|
57
|
+
"customVariableName2": "post-age",
|
|
58
|
+
"customVariableValue2": "-430 days"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"type": "action",
|
|
64
|
+
"url": "https://dive-shop.net/products/diving-boots/",
|
|
65
|
+
"pageTitle": "Divezone Brand Diving Boots - Divezone Store",
|
|
66
|
+
"pageIdAction": "60",
|
|
67
|
+
"idpageview": "8CDIez",
|
|
68
|
+
"serverTimePretty": "20 août 2021 21:30:25",
|
|
69
|
+
"pageId": "19696664",
|
|
70
|
+
"timeSpent": "2",
|
|
71
|
+
"timeSpentPretty": "2s",
|
|
72
|
+
"pageviewPosition": "5",
|
|
73
|
+
"title": "Divezone Brand Diving Boots - Divezone Store",
|
|
74
|
+
"subtitle": "https://dive-shop.net/products/diving-boots/",
|
|
75
|
+
"icon": "",
|
|
76
|
+
"iconSVG": "plugins/Morpheus/images/action.svg",
|
|
77
|
+
"timestamp": 1629495025,
|
|
78
|
+
"dimension2": "julien",
|
|
79
|
+
"dimension4": "indonesia",
|
|
80
|
+
"dimension5": "diving"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "search",
|
|
84
|
+
"url": "https://dive-shop.net/products/diving-boots/",
|
|
85
|
+
"pageTitle": "Divezone Brand Diving Boots - Divezone Store",
|
|
86
|
+
"pageIdAction": "60",
|
|
87
|
+
"idpageview": "8CDIez",
|
|
88
|
+
"serverTimePretty": "20 août 2021 21:30:25",
|
|
89
|
+
"pageId": "19696664",
|
|
90
|
+
"timeSpent": "2",
|
|
91
|
+
"timeSpentPretty": "2s",
|
|
92
|
+
"pageviewPosition": "5",
|
|
93
|
+
"title": "Divezone Brand Diving Boots - Divezone Store",
|
|
94
|
+
"subtitle": "https://dive-shop.net/products/diving-boots/",
|
|
95
|
+
"icon": "",
|
|
96
|
+
"iconSVG": "plugins/Morpheus/images/action.svg",
|
|
97
|
+
"timestamp": 1629495022,
|
|
98
|
+
"dimension2": "julien",
|
|
99
|
+
"dimension4": "indonesia",
|
|
100
|
+
"dimension5": "diving",
|
|
101
|
+
"siteSearchKeyword": "scuba"
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const MATOMO_KEY = process.env.MATOMO_KEY || '';
|
|
2
|
+
export const MATOMO_URL = process.env.MATOMO_URL || 'https://matomo.fabrique.social.gouv.fr/';
|
|
3
|
+
export const MATOMO_SITE = process.env.MATOMO_SITE || 0;
|
|
4
|
+
export const PGDATABASE = process.env.PGDATABASE || '';
|
|
5
|
+
export const INITIAL_OFFSET = process.env.INITIAL_OFFSET || '3';
|
|
6
|
+
export const RESULTPERPAGE = process.env.RESULTPERPAGE || '500';
|
|
7
|
+
export const FORCE_STARTDATE = process.env.FORCE_STARTDATE === 'true';
|
|
8
|
+
// We will create both a normal and a partitioned table (MATOMO_TABLE_NAME and PARTITIONED_MATOMO_TABLE_NAME)
|
|
9
|
+
// and use DESTINATION_TABLE to determine which one to write to.
|
|
10
|
+
export const DESTINATION_TABLE = process.env.DESTINATION_TABLE || 'matomo';
|
|
11
|
+
export const MATOMO_TABLE_NAME = process.env.MATOMO_TABLE_NAME || 'matomo';
|
|
12
|
+
export const PARTITIONED_MATOMO_TABLE_NAME = process.env.PARTITIONED_MATOMO_TABLE_NAME || 'matomo_partitioned';
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import startDebug from 'debug';
|
|
2
|
+
import { Kysely, PostgresDialect } from 'kysely';
|
|
3
|
+
import pkg from 'pg';
|
|
4
|
+
const { Pool } = pkg;
|
|
5
|
+
import { PGDATABASE } from './config.js';
|
|
6
|
+
startDebug('db');
|
|
7
|
+
export const pool = new Pool({
|
|
8
|
+
connectionString: PGDATABASE,
|
|
9
|
+
ssl: {
|
|
10
|
+
rejectUnauthorized: false
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
// Validate pool is properly initialized
|
|
14
|
+
if (!pool || typeof pool.connect !== 'function') {
|
|
15
|
+
throw new Error('Failed to initialize PostgreSQL connection pool');
|
|
16
|
+
}
|
|
17
|
+
export const db = new Kysely({
|
|
18
|
+
dialect: new PostgresDialect({
|
|
19
|
+
pool: pool
|
|
20
|
+
}),
|
|
21
|
+
log(event) {
|
|
22
|
+
if (event.level === 'query') {
|
|
23
|
+
// debug(event.query.sql)
|
|
24
|
+
// debug(event.query.parameters)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
// Validate the Kysely instance
|
|
29
|
+
if (!db) {
|
|
30
|
+
throw new Error('Failed to initialize Kysely database instance');
|
|
31
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { formatISO } from 'date-fns';
|
|
11
|
+
import startDebug from 'debug';
|
|
12
|
+
import { sql } from 'kysely';
|
|
13
|
+
import pAll from 'p-all';
|
|
14
|
+
import { DESTINATION_TABLE, MATOMO_SITE, RESULTPERPAGE } from './config.js';
|
|
15
|
+
import { db, pool } from './db.js';
|
|
16
|
+
import { getEventsFromMatomoVisit, importEvent } from './importEvent.js';
|
|
17
|
+
const debug = startDebug('importDate');
|
|
18
|
+
/** return date as ISO yyyy-mm-dd */
|
|
19
|
+
const isoDate = (date) => formatISO(date, { representation: 'date' });
|
|
20
|
+
/** check how many visits complete for a given date */
|
|
21
|
+
const getRecordsCount = (date) => __awaiter(void 0, void 0, void 0, function* () {
|
|
22
|
+
if (!pool || typeof pool.connect !== 'function') {
|
|
23
|
+
throw new Error('Database connection pool is invalid or undefined in getRecordsCount');
|
|
24
|
+
}
|
|
25
|
+
const result = yield db
|
|
26
|
+
.selectFrom(DESTINATION_TABLE)
|
|
27
|
+
.select(db.fn.count('idvisit').distinct().as('count'))
|
|
28
|
+
// UTC to be iso with matomo matomo data
|
|
29
|
+
.where(sql `date(timezone('UTC', action_timestamp))`, '=', date)
|
|
30
|
+
.executeTakeFirst();
|
|
31
|
+
// start at previous visit in case action didnt finished to record
|
|
32
|
+
const count = Math.max(0, (result && parseInt(result.count) - 1) || 0);
|
|
33
|
+
return count;
|
|
34
|
+
});
|
|
35
|
+
/** import all event from givent date */
|
|
36
|
+
export const importDate = (piwikApi_1, date_1, ...args_1) => __awaiter(void 0, [piwikApi_1, date_1, ...args_1], void 0, function* (piwikApi, date, filterOffset = 0) {
|
|
37
|
+
const limit = parseInt(RESULTPERPAGE);
|
|
38
|
+
// Guard against misconfiguration that can cause infinite pagination loops
|
|
39
|
+
// (e.g. limit=NaN makes `visits.length < limit` always false).
|
|
40
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
41
|
+
throw new Error(`Invalid RESULTPERPAGE: expected a positive integer, got '${RESULTPERPAGE}'`);
|
|
42
|
+
}
|
|
43
|
+
const dateIso = isoDate(date);
|
|
44
|
+
let offset = filterOffset || (yield getRecordsCount(dateIso));
|
|
45
|
+
if (!Number.isFinite(offset) || offset < 0) {
|
|
46
|
+
offset = 0;
|
|
47
|
+
}
|
|
48
|
+
let pages = 0;
|
|
49
|
+
let visitsFetched = 0;
|
|
50
|
+
let eventsImported = 0;
|
|
51
|
+
while (true) {
|
|
52
|
+
pages += 1;
|
|
53
|
+
if (!offset) {
|
|
54
|
+
debug(`${dateIso}: load ${limit} visits`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
debug(`${dateIso}: load ${limit} more visits after ${offset}`);
|
|
58
|
+
}
|
|
59
|
+
// fetch visits details
|
|
60
|
+
const visits = yield new Promise((resolve, reject) => piwikApi({
|
|
61
|
+
method: 'Live.getLastVisitsDetails',
|
|
62
|
+
period: 'day',
|
|
63
|
+
date: dateIso,
|
|
64
|
+
// minTimestamp: isoDate(new Date()) === isoDate(date) ? date.getTime() / 1000 : undefined, // if today, dont go further (??)
|
|
65
|
+
filter_limit: limit,
|
|
66
|
+
filter_offset: offset,
|
|
67
|
+
filter_sort_order: 'asc',
|
|
68
|
+
idSite: MATOMO_SITE
|
|
69
|
+
}, (err, visits = []) => {
|
|
70
|
+
if (err) {
|
|
71
|
+
return reject(err);
|
|
72
|
+
}
|
|
73
|
+
return resolve(visits);
|
|
74
|
+
}));
|
|
75
|
+
visitsFetched += visits.length;
|
|
76
|
+
debug(`fetched ${visits.length} visits`);
|
|
77
|
+
// flatten all events
|
|
78
|
+
const allEvents = visits.flatMap(getEventsFromMatomoVisit);
|
|
79
|
+
if (allEvents.length) {
|
|
80
|
+
debug(`import ${allEvents.length} events`);
|
|
81
|
+
// import events into PG
|
|
82
|
+
yield pAll(allEvents.map((event) => () => importEvent(event)), { concurrency: 10, stopOnError: true });
|
|
83
|
+
eventsImported += allEvents.length;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
debug(`no events to import on this page (${dateIso}, offset ${offset})`);
|
|
87
|
+
}
|
|
88
|
+
// stop if we didn't fetch a full page
|
|
89
|
+
if (visits.length < limit) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
offset += limit;
|
|
93
|
+
}
|
|
94
|
+
debug(`finished importing ${dateIso}, pages ${pages}, visits ${visitsFetched}`);
|
|
95
|
+
return {
|
|
96
|
+
date: dateIso,
|
|
97
|
+
pages,
|
|
98
|
+
visitsFetched,
|
|
99
|
+
eventsImported
|
|
100
|
+
};
|
|
101
|
+
});
|