Skip to content

Commit 2f40c94

Browse files
Push master to prod (#54)
* Satisfy minimum requirements for full typescript project (#37) * Feature: satisfy minimum for full typescript * Feature: change report structure from array to object * Chore: jsdoc * Refactor: PR review suggestions * Refactor: type out simple reports * Refactor: rename variables * Refactor: more types for now some logic errors * Chore: docs * Fix: type -> interface * Chore: comments * Type refactor * Improve report to discord message * Make it optional * Improve discord message creation * renames * Record to index * Refactor: Add additional type safety, refactor structure and naming, jsdocs (#38) * Feature: satisfy minimum for full typescript * Feature: change report structure from array to object * Chore: jsdoc * Feature: add additional types, jsdocs * Remove unnecessary error typing * Feature: type some more * Chore: typo fix * Chore: rename chart func, jsdocs improvements * Feature: add docs, fix types * Feature: remove all implicit any types * Refactor: types to interfaces as default * Refactor: PR review suggestions * Refactor: type out simple reports * Refactor: rename variables * Refactor: more types for now some logic errors * Chore: docs * Fix: type -> interface * Chore: remove unnecessary async * Chore: remove unnecessary jsdoc * Chore: comments * Feature: table improvements * Refactor: naming * Type refactor * Improve report to discord message * Make it optional * Improve discord message creation * Some typing * jsdocs type * strict tsconfig + miho improvements * Move default value to inisde of function * Remove comments * missing types + comments * improve `loadCachedEvents` logic * remove rendundant satisfies * remove Array for [] * renames + types * makes quotes consistent * fix filter error * remove custom intersection impl * head has extra "" not needing extra "right" because of it * typo * move constants * Chore: Update easy to update packages (#47) * update easy to update packages * return axios whoops * Setup dotenv to work on wasp-bot (#48) * vault * vault 2 * spaces * Refactor: Improve Ignore files, rename example file (#50) * add comments * docker ignore update * update * test * typo * example docker ignroe changes * comments * update * update comments * Refactor discord bot logic into multiple files (#45) * split single file * awaits and types * unnecessary memeber * comments -> functions * name, structyure * functions * de-modularaize a bit * comment * login last * analytics * quote * type external quote * imports * handlers, remove role id from channel ids * refactors * server ids * refacotr * refactor 2 * update help * revert * Update src/discord/bot/analytics/daily-report.ts Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com> * naming * commands * fix regex dep * extract prefix instead * improve naming --------- Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com> * Cohort retention charts + POC of rendering charts on node and send them as attachment (#49) * chart work * better colors and visualization * logic cleanup * imports * new colors * updates * remove jsdocs extras * comments * refactor * docs * make return type work correctly * remove old discord bot file * Fix font issues, cross platfrom Dockerfile, no events cache crash, add buffer charts CLI display (#53) * make Dockerfile cross-platform, handle no file error * and cli charts, fix cohort retention, add types * fonts * console to logger * console -> logger * fonts fixes * cover fonts * fix * fix fonts * yupe * pr review * comments * loadsh * fonts update * Delete pnpm-lock.yaml * remove logs * jsdocs * naming * naming * tone down jsdocs, naming fix * easier read * make font file name parsing easier * Update Dockerfile Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com> * extra comment line * Update Dockerfile Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com> --------- Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com> * remove old code (#55) --------- Co-authored-by: Martin Šošić <Martinsos@users.noreply.github.com>
1 parent 1f03f05 commit 2f40c94

21 files changed

+201
-47
lines changed

Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ FROM node:22-slim AS builder
22

33
WORKDIR /app
44

5+
RUN apt-get update -qq && \
6+
# Install dependencies for building native node modules (added by fly.io by default).
7+
apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 \
8+
# Install dependencies for node-canvas (used by chartjs-node-canvas).
9+
libcairo2-dev libpango1.0-dev
10+
511
# Copy package files
612
COPY package*.json ./
713

213 KB
Binary file not shown.
228 KB
Binary file not shown.
214 KB
Binary file not shown.
Binary file not shown.
228 KB
Binary file not shown.
214 KB
Binary file not shown.
229 KB
Binary file not shown.
213 KB
Binary file not shown.
228 KB
Binary file not shown.
213 KB
Binary file not shown.
213 KB
Binary file not shown.
Binary file not shown.
214 KB
Binary file not shown.
229 KB
Binary file not shown.

src/analytics/cli.ts

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import fs from "fs";
2+
import os from "os";
13
import logger from "../utils/logger";
24
import { getAnalyticsErrorMessage } from "./errors";
35
import * as reports from "./reports";
46
import {
57
AllTimePeriodReport,
8+
ChartReport,
69
ImageChartsReport,
710
TextReport,
811
} from "./reports/reports";
@@ -28,6 +31,43 @@ async function cliReport(): Promise<void> {
2831
);
2932
}
3033

34+
function printReportTitle(text: string): void {
35+
console.log(`\x1b[33m \n\n${text} \x1b[0m`);
36+
}
37+
38+
function printReportInCLI(compositeReport: {
39+
[reportName: string]: Partial<TextReport & ImageChartsReport & ChartReport>;
40+
}): void {
41+
for (const [name, simpleReport] of Object.entries(compositeReport)) {
42+
console.log();
43+
if (simpleReport.text) {
44+
for (const textLine of simpleReport.text) {
45+
console.log(textLine);
46+
}
47+
}
48+
if (simpleReport.imageChartsChart) {
49+
console.log(
50+
"- ImagesCharts Chart: ",
51+
simpleReport.imageChartsChart.toURL(),
52+
);
53+
}
54+
if (simpleReport.bufferChart) {
55+
const filePath = createTmpImageFile(name, simpleReport.bufferChart);
56+
console.log("- Buffer Chart: ", filePath);
57+
}
58+
}
59+
}
60+
61+
function createTmpImageFile(name: string, buffer: Buffer): string {
62+
const tempDir = os.tmpdir();
63+
const fileName = `${name}-${Date.now()}.png`;
64+
const filePath = `${tempDir}/${fileName}`;
65+
66+
fs.writeFileSync(filePath, buffer);
67+
68+
return filePath;
69+
}
70+
3171
/**
3272
* Outputs CSV of total metrics since the start of tracking them,
3373
* while skipping cohort analytis because that would be too complex.
@@ -50,26 +90,6 @@ function printAllTimeMonthlyReportCsvInCLI(
5090
}
5191
}
5292

53-
function printReportInCLI(
54-
compositeReport: Record<string, Partial<TextReport & ImageChartsReport>>,
55-
): void {
56-
for (const simpleReport of Object.values(compositeReport)) {
57-
console.log();
58-
if (simpleReport.text) {
59-
for (const textLine of simpleReport.text) {
60-
console.log(textLine);
61-
}
62-
}
63-
if (simpleReport.imageChartsChart) {
64-
console.log("- Chart: ", simpleReport.imageChartsChart.toURL());
65-
}
66-
}
67-
}
68-
69-
function printReportTitle(text: string): void {
70-
console.log(`\x1b[33m \n\n${text} \x1b[0m`);
71-
}
72-
7393
cliReport().catch((e) => {
7494
const message = getAnalyticsErrorMessage(e);
7595
logger.error(message);

src/analytics/events.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import retry from "async-retry";
22
import axios from "axios";
33
import { config as dotenvConfig } from "dotenv";
44
import { promises as fs } from "fs";
5+
import logger from "../utils/logger";
56
import moment from "./moment";
67

78
dotenvConfig();
@@ -43,7 +44,7 @@ export async function tryToFetchAllCliEvents(): Promise<PosthogEvent[]> {
4344
minTimeout: 5 * 1000,
4445
maxTimeout: 120 * 1000,
4546
onRetry: (e: Error) => {
46-
console.error(
47+
logger.error(
4748
"Error happened while fetching events for report generator, trying again:",
4849
e.message ?? e,
4950
);
@@ -53,18 +54,18 @@ export async function tryToFetchAllCliEvents(): Promise<PosthogEvent[]> {
5354
}
5455

5556
async function fetchAllCliEvents(): Promise<PosthogEvent[]> {
56-
console.log("Fetching all CLI events...");
57+
logger.info("Fetching all CLI events...");
5758

5859
const cachedEvents = await loadCachedEvents();
59-
console.log("Number of already locally cached events: ", cachedEvents.length);
60+
logger.info("Number of already locally cached events: ", cachedEvents.length);
6061

6162
let events = cachedEvents;
6263

6364
// We fetch any events older than the currently oldest event we already have.
6465
// If we have no events already, we just start from the newest ones.
6566
// They are fetched starting with the newest ones and going backwards.
6667
// We keep fetching them and adding them to the cache as we go, until there are none left.
67-
console.log("Fetching events older than the cache...");
68+
logger.info("Fetching events older than the cache...");
6869
let allOldEventsFetched = false;
6970
while (!allOldEventsFetched) {
7071
const { isThereMore, events: fetchedEvents } = await fetchEvents({
@@ -79,7 +80,7 @@ async function fetchAllCliEvents(): Promise<PosthogEvent[]> {
7980
// We fetch any events newer than the currently newest event we already have.
8081
// They are fetched starting with the newest ones and going backwards.
8182
// Only once we fetch all of them, we add them to the cache. This is done to guarantee continuity of cached events.
82-
console.log("Fetching events newer than the cache...");
83+
logger.info("Fetching events newer than the cache...");
8384
let newEvents: PosthogEvent[] = [];
8485
let allNewEventsFetched = false;
8586
while (!allNewEventsFetched) {
@@ -109,7 +110,7 @@ async function fetchAllCliEvents(): Promise<PosthogEvent[]> {
109110
);
110111
}
111112

112-
console.log("All events fetched!");
113+
logger.info("All events fetched!");
113114
return events;
114115
}
115116

@@ -149,7 +150,7 @@ async function fetchEvents({
149150
params,
150151
).toString()}`;
151152

152-
console.log(`Fetching: ${url}`);
153+
logger.info(`Fetching: ${url}`);
153154
const response = await axios.get(url, {
154155
headers: {
155156
Authorization: `Bearer ${POSTHOG_KEY}`,
@@ -173,10 +174,11 @@ async function fetchEvents({
173174
* - There might be missing events before or after the cached range
174175
*/
175176
async function loadCachedEvents(): Promise<PosthogEvent[]> {
176-
const cacheFileContent = await fs.readFile(CACHE_FILE_PATH, "utf-8");
177-
if (cacheFileContent !== "") {
177+
try {
178+
const cacheFileContent = await fs.readFile(CACHE_FILE_PATH, "utf-8");
178179
return JSON.parse(cacheFileContent);
179-
} else {
180+
} catch (e: unknown) {
181+
logger.warning(`Failed to read cache file: ${e}`);
180182
return [];
181183
}
182184
}

src/analytics/reports/events.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import _ from "lodash";
22

3+
import logger from "../../utils/logger";
34
import { addEventContextValueIfMissing } from "../eventContext";
45
import { type PosthogEvent, tryToFetchAllCliEvents } from "../events";
56
import { executionEnvs } from "../executionEnvs";
@@ -12,7 +13,7 @@ import moment from "../moment";
1213
export async function fetchEventsForReportGenerator(): Promise<PosthogEvent[]> {
1314
const allEvents = await tryToFetchAllCliEvents();
1415

15-
console.log("\nNumber of CLI events fetched:", allEvents.length);
16+
logger.info("\nNumber of CLI events fetched:", allEvents.length);
1617

1718
const waspTeamFilters = [isNotWaspTeamEvent, isNotMihoPrivateCIServerEvent];
1819
const nonWaspTeamEvents = waspTeamFilters.reduce(

src/analytics/reports/periodReport/cohortRetentionReport.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ async function createCohortRetentionHeatMap(
208208
},
209209
borderColor: "rgba(0, 0, 0, 0.1)",
210210
borderWidth: 1,
211+
anchorY: "top",
212+
anchorX: "left",
211213
};
212214

213215
const chartConfiguration: ChartConfiguration<
@@ -224,38 +226,38 @@ async function createCohortRetentionHeatMap(
224226
x: {
225227
type: "linear",
226228
min: 0,
227-
max: periods.length - 1,
229+
max: periods.length,
228230
ticks: {
229231
stepSize: 1,
230232
callback: (value) => `+${value}`,
231-
font: { size: 12 },
233+
font: { size: 12, weight: 500 },
232234
},
233-
offset: true,
235+
offset: false,
234236
title: {
235237
display: true,
236238
text: `Cohort Progression (per ${periodName})`,
237239
font: {
238240
size: 14,
239-
weight: "bold",
241+
weight: 700,
240242
},
241243
},
242244
},
243245
y: {
244246
type: "linear",
245247
min: 0,
246-
max: cohorts.length - 1,
248+
max: cohorts.length,
247249
ticks: {
248250
stepSize: 1,
249251
callback: (value) => `#${value}`,
250-
font: { size: 12 },
252+
font: { size: 12, weight: 500 },
251253
},
252-
offset: true,
254+
offset: false,
253255
title: {
254256
display: true,
255257
text: "Cohort Start",
256258
font: {
257259
size: 14,
258-
weight: "bold",
260+
weight: 700,
259261
},
260262
},
261263
},
@@ -270,7 +272,7 @@ async function createCohortRetentionHeatMap(
270272
align: "center",
271273
font: {
272274
size: 20,
273-
weight: "bold",
275+
weight: 700,
274276
},
275277
padding: {
276278
top: 10,
@@ -325,7 +327,7 @@ const cohortRetentionChartCellLabels: Plugin<"matrix"> = {
325327
canvasContext.save();
326328
canvasContext.fillStyle =
327329
getFontColorForBackgroundColor(backgroundColor);
328-
canvasContext.font = "12px sans-serif";
330+
canvasContext.font = "12px";
329331
canvasContext.textAlign = "center";
330332
canvasContext.textBaseline = "middle";
331333
canvasContext.fillText(label, x + width / 2, y + height / 2);

src/charts/canvas/fonts.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { registerFont } from "canvas";
2+
import * as fs from "fs";
3+
import * as path from "path";
4+
5+
export const defaultFont = "IBMPlexSans";
6+
const fontFileExtensions = [".ttf", ".otf", ".woff", ".woff2"];
7+
8+
/**
9+
* Registers embedded fonts for use with `node-canvas`.
10+
*
11+
* Embedded fonts are fonts that we bundle together with the application.
12+
* They can be found in the [`/fonts` directory](../../../fonts).
13+
*
14+
* These fonts are available in addition to the system fonts.
15+
* We use embedded fonts instead of system fonts to ensure consistent, predictable rendering
16+
* across all environments (e.g., Docker, CI, Linux, macOS, Windows).
17+
*/
18+
export function registerEmbeddedFonts(): void {
19+
const fontsDir = path.resolve(__dirname, "../../../fonts");
20+
registerFontsFromDir(fontsDir);
21+
}
22+
23+
/**
24+
* Registers all supported font files from a given absolute directory path
25+
* for use with `node-canvas`.
26+
*
27+
* Only static fonts are supported. Variable fonts (e.g., `.ttf` files with multiple weight/width axes)
28+
* are not usable with `node-canvas` and will be ignored or may cause unexpected behavior.
29+
*/
30+
function registerFontsFromDir(absDirPath: string): void {
31+
for (const absFontPath of collectAbsFontPathsRecursively(absDirPath)) {
32+
const fileName = path.basename(absFontPath);
33+
const fontFace = parseFontFileName(fileName);
34+
35+
registerFont(absFontPath, fontFace);
36+
}
37+
}
38+
39+
/**
40+
* Recursively collects absolute path of all font files from a directory.
41+
*/
42+
function collectAbsFontPathsRecursively(absDirPath: string): string[] {
43+
return fs
44+
.readdirSync(absDirPath, {
45+
recursive: true,
46+
encoding: "utf8",
47+
})
48+
.map((fileName) => path.join(absDirPath, fileName))
49+
.filter((fileName) =>
50+
fontFileExtensions.some((ext) => fileName.endsWith(ext)),
51+
);
52+
}
53+
54+
interface FontFace {
55+
family: string;
56+
weight: string;
57+
style: string;
58+
}
59+
60+
/**
61+
* Parses a font file name to extract the font face information.
62+
*
63+
* The expected format is: `FontFamily-FontStyle.ext`
64+
* For example: `IBMPlexSans-Regular.ttf` or `IBMPlexSans-BoldItalic.otf`.
65+
*
66+
* This format is known as the "Weight-Stretch-Slope" (WWS) naming model.
67+
* It is also known as "Weight-Width-Slope" format.
68+
* However, the format is not universal, but widely accepted standard.
69+
*
70+
* Currently as `node-canvas` doesn't support stretch,
71+
* so we also don't include it in the parsing.
72+
*
73+
* @throws {Error} If the font style is not recognized.
74+
*/
75+
function parseFontFileName(fileName: string): FontFace {
76+
const stem = fileName.split(".")[0];
77+
const [family, style] = stem.split("-");
78+
79+
const fontAttributes = fontStyleToFontAttributes[style];
80+
if (!fontAttributes) {
81+
throw new Error(
82+
`Font style "${style}" is not recognized. Supported font styles are: ${Object.keys(
83+
fontStyleToFontAttributes,
84+
).join(", ")}`,
85+
);
86+
}
87+
88+
return {
89+
family,
90+
...fontAttributes,
91+
};
92+
}
93+
94+
const fontStyleToFontAttributes: {
95+
[fontStyle: string]: Omit<FontFace, "family">;
96+
} = {
97+
Thin: { weight: "100", style: "normal" },
98+
ThinItalic: { weight: "100", style: "italic" },
99+
ExtraLight: { weight: "200", style: "normal" },
100+
ExtraLightItalic: { weight: "200", style: "italic" },
101+
Light: { weight: "300", style: "normal" },
102+
LightItalic: { weight: "300", style: "italic" },
103+
Regular: { weight: "normal", style: "normal" },
104+
Italic: { weight: "normal", style: "italic" },
105+
Medium: { weight: "500", style: "normal" },
106+
MediumItalic: { weight: "500", style: "italic" },
107+
SemiBold: { weight: "600", style: "normal" },
108+
SemiBoldItalic: { weight: "600", style: "italic" },
109+
Bold: { weight: "bold", style: "normal" },
110+
BoldItalic: { weight: "bold", style: "italic" },
111+
ExtraBold: { weight: "800", style: "normal" },
112+
ExtraBoldItalic: { weight: "800", style: "italic" },
113+
Black: { weight: "900", style: "normal" },
114+
BlackItalic: { weight: "900", style: "italic" },
115+
};

0 commit comments

Comments
 (0)