Skip to content

Commit 9a7fba6

Browse files
authored
feat(blog): add automated PostHog tiles update workflow (#7978)
* feat(blog): add automated PostHog tiles update workflow * Coderabbit fixes
1 parent 7ad9022 commit 9a7fba6

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Blog PostHog Tiles
2+
3+
# Re-bakes the static "Blog posts published per month" and "Blog library age"
4+
# PostHog tiles from frontmatter publish dates whenever blog content changes,
5+
# so they stay current with no manual refresh. See
6+
# apps/blog/scripts/update-posthog-blog-tiles.mjs.
7+
8+
on:
9+
workflow_dispatch:
10+
push:
11+
branches: [main]
12+
paths: [apps/blog/content/blog/**/*.mdx]
13+
14+
concurrency:
15+
group: ${{ github.workflow }}-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
permissions: {}
19+
20+
jobs:
21+
update-tiles:
22+
name: Update PostHog blog tiles
23+
timeout-minutes: 10
24+
runs-on: ubuntu-latest
25+
26+
steps:
27+
- name: Checkout repository
28+
uses: actions/checkout@v4
29+
30+
- name: Setup Node.js
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: "20"
34+
35+
- name: Re-bake PostHog tiles from frontmatter
36+
run: node apps/blog/scripts/update-posthog-blog-tiles.mjs
37+
env:
38+
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Re-bakes the static PostHog blog tiles from blog frontmatter so they stay
2+
// current with no manual refresh.
3+
//
4+
// Tiles updated (PostHog project 60295, dashboard "Blog SEO & Performance"):
5+
// - "Blog posts published per month" (insight 9555387 / 1eGn424G)
6+
// - "Blog library age — older vs younger 12mo" (insight 9555402 / IasjsfXT)
7+
// - "Recent posts ranked by pageviews" (insight 9497434 / D2eDZ3eq)
8+
//
9+
// Run by .github/workflows/blog-posthog-tiles.yml on every push to main that
10+
// touches apps/blog/content/blog/**. The first two tiles are driven by a
11+
// publish-month histogram (frontmatter `date`); the recent-posts tile is driven
12+
// by the list of posts published OR updated in the last 30 days (frontmatter
13+
// `date` / `updatedAt`).
14+
//
15+
// Local dry run (prints the queries, no API call):
16+
// node apps/blog/scripts/update-posthog-blog-tiles.mjs --dry-run
17+
18+
import { readFileSync, readdirSync } from "node:fs";
19+
import { fileURLToPath } from "node:url";
20+
import { dirname, join } from "node:path";
21+
22+
const PROJECT_ID = 60295;
23+
const HOST = "https://us.posthog.com";
24+
25+
// Numeric insight ids (short ids in comments) on the Blog SEO & Performance dashboard.
26+
const INSIGHTS = {
27+
postsPerMonth: 9555387, // 1eGn424G
28+
libraryAge: 9555402, // IasjsfXT
29+
recentPosts: 9497434, // D2eDZ3eq
30+
};
31+
32+
const RECENT_DAYS = 30;
33+
34+
const CONTENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "content", "blog");
35+
36+
/** Read the frontmatter block of every post: { slug, date, updatedAt }. */
37+
function readPosts() {
38+
const posts = [];
39+
for (const entry of readdirSync(CONTENT_DIR, { withFileTypes: true })) {
40+
if (!entry.isDirectory()) continue;
41+
let raw;
42+
try {
43+
raw = readFileSync(join(CONTENT_DIR, entry.name, "index.mdx"), "utf8");
44+
} catch {
45+
continue; // directory without an index.mdx
46+
}
47+
const fm = raw.startsWith("---") ? (raw.split(/^---\s*$/m)[1] ?? "") : raw;
48+
const date = (fm.match(/^date:\s*["']?(\d{4}-\d{2}-\d{2})/m) ?? [])[1] ?? null;
49+
const updatedAt = (fm.match(/^updatedAt:\s*["']?(\d{4}-\d{2}-\d{2})/m) ?? [])[1] ?? null;
50+
posts.push({ slug: entry.name, date, updatedAt });
51+
}
52+
return posts;
53+
}
54+
55+
/** Sorted [ ['YYYY-MM', count], ... ] histogram of publish months. */
56+
function publishHistogram(posts) {
57+
const counts = new Map();
58+
for (const { date } of posts) {
59+
if (!date) continue;
60+
const month = date.slice(0, 7);
61+
counts.set(month, (counts.get(month) ?? 0) + 1);
62+
}
63+
return [...counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1));
64+
}
65+
66+
/** Slugs of posts published OR updated within the last RECENT_DAYS days. */
67+
function recentSlugs(posts) {
68+
const cutoff = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString().slice(0, 10);
69+
return posts
70+
.filter((p) => (p.date && p.date > cutoff) || (p.updatedAt && p.updatedAt > cutoff))
71+
.map((p) => p.slug)
72+
.sort();
73+
}
74+
75+
function tuplesLiteral(hist) {
76+
return hist.map(([month, count]) => `tuple('${month}',${count})`).join(",");
77+
}
78+
79+
function postsPerMonthQuery(tuples) {
80+
return {
81+
kind: "DataVisualizationNode",
82+
display: "ActionsBar",
83+
source: {
84+
kind: "HogQLQuery",
85+
query: `SELECT toDate(concat(t.1, '-01')) AS month, t.2 AS posts FROM (SELECT arrayJoin([${tuples}]) AS t) ORDER BY month`,
86+
},
87+
chartSettings: {
88+
xAxis: { column: "month" },
89+
yAxis: [{ column: "posts", settings: { display: { label: "Posts published" } } }],
90+
},
91+
};
92+
}
93+
94+
function libraryAgeQuery(tuples) {
95+
const query =
96+
`WITH hist AS (SELECT toDate(concat(t.1, '-01')) AS pub, t.2 AS cnt FROM (SELECT arrayJoin([${tuples}]) AS t)) ` +
97+
`SELECT M AS month, ` +
98+
`sumIf(cnt, pub > M - toIntervalMonth(12) AND pub <= M) AS younger_than_12mo, ` +
99+
`sumIf(cnt, pub <= M - toIntervalMonth(12)) AS older_than_12mo ` +
100+
`FROM (SELECT arrayJoin(arrayMap(i -> toStartOfMonth(today()) - toIntervalMonth(i), range(0, 36))) AS M) months ` +
101+
`CROSS JOIN hist GROUP BY M ORDER BY M`;
102+
return {
103+
kind: "DataVisualizationNode",
104+
display: "ActionsStackedBar",
105+
source: { kind: "HogQLQuery", query },
106+
chartSettings: {
107+
xAxis: { column: "month" },
108+
yAxis: [
109+
{ column: "younger_than_12mo", settings: { display: { label: "< 12 months" } } },
110+
{ column: "older_than_12mo", settings: { display: { label: "> 12 months" } } },
111+
],
112+
},
113+
};
114+
}
115+
116+
function recentPostsQuery(slugs) {
117+
const quote = (s) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
118+
const arr = slugs.length ? `[${slugs.map(quote).join(",")}]` : "CAST([] AS Array(String))";
119+
const query =
120+
`WITH recent AS (SELECT arrayJoin(${arr}) AS slug), ` +
121+
`pv AS (SELECT properties.$pathname AS path, count() AS pageviews, count(DISTINCT person_id) AS visitors ` +
122+
`FROM events WHERE event = '$pageview' AND properties.$host = 'www.prisma.io' ` +
123+
`AND timestamp >= now() - INTERVAL 30 DAY AND properties.$pathname LIKE '/blog/%' GROUP BY path) ` +
124+
`SELECT path, pageviews, visitors, rank, multiIf(rank <= 3, 'Top 3', rank > total - 3, 'Bottom 3', '') AS tier ` +
125+
`FROM (SELECT concat('/blog/', recent.slug) AS path, coalesce(pv.pageviews, 0) AS pageviews, ` +
126+
`coalesce(pv.visitors, 0) AS visitors, row_number() OVER (ORDER BY coalesce(pv.pageviews, 0) DESC) AS rank, ` +
127+
`count() OVER () AS total FROM recent LEFT JOIN pv ON pv.path = concat('/blog/', recent.slug)) ` +
128+
`ORDER BY rank LIMIT 100`;
129+
return { kind: "DataVisualizationNode", display: "ActionsTable", source: { kind: "HogQLQuery", query } };
130+
}
131+
132+
async function patchInsight(id, query) {
133+
const res = await fetch(`${HOST}/api/projects/${PROJECT_ID}/insights/${id}/`, {
134+
method: "PATCH",
135+
headers: {
136+
"Content-Type": "application/json",
137+
Authorization: `Bearer ${process.env.POSTHOG_API_KEY}`,
138+
},
139+
body: JSON.stringify({ query }),
140+
});
141+
if (!res.ok) {
142+
throw new Error(`PATCH insight ${id} failed: ${res.status} ${await res.text()}`);
143+
}
144+
}
145+
146+
const posts = readPosts();
147+
const hist = publishHistogram(posts);
148+
const tuples = tuplesLiteral(hist);
149+
const total = hist.reduce((sum, [, count]) => sum + count, 0);
150+
const recent = recentSlugs(posts);
151+
console.log(
152+
`Blog posts: ${total} across ${hist.length} months (${hist[0]?.[0]}${hist.at(-1)?.[0]}); ${recent.length} recent (≤${RECENT_DAYS}d).`,
153+
);
154+
155+
if (process.argv.includes("--dry-run")) {
156+
console.log("\nDRY RUN — queries that would be written (no API call):\n");
157+
console.log("posts-per-month:\n" + postsPerMonthQuery(tuples).source.query + "\n");
158+
console.log("library-age:\n" + libraryAgeQuery(tuples).source.query + "\n");
159+
console.log("recent slugs (" + recent.length + "): " + recent.join(", ") + "\n");
160+
console.log("recent-posts:\n" + recentPostsQuery(recent).source.query);
161+
process.exit(0);
162+
}
163+
164+
if (!process.env.POSTHOG_API_KEY) {
165+
console.error("Error: POSTHOG_API_KEY is not set. Set the repo secret, or pass --dry-run to preview.");
166+
process.exit(1);
167+
}
168+
169+
await patchInsight(INSIGHTS.postsPerMonth, postsPerMonthQuery(tuples));
170+
console.log(`Updated posts-per-month insight (${INSIGHTS.postsPerMonth}).`);
171+
await patchInsight(INSIGHTS.libraryAge, libraryAgeQuery(tuples));
172+
console.log(`Updated library-age insight (${INSIGHTS.libraryAge}).`);
173+
await patchInsight(INSIGHTS.recentPosts, recentPostsQuery(recent));
174+
console.log(`Updated recent-posts insight (${INSIGHTS.recentPosts}).`);
175+
console.log("Done.");

0 commit comments

Comments
 (0)