feat: reddit data (#150)

* feat: reddit data

* feat: reddit data backend and backfill

* fix: interpolate all metrics for when some data is missing

* chore: move cron job to every 5 minutes

---------

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
Jason Rasmussen
2025-09-17 23:58:29 -04:00
committed by GitHub
co-authored by Zack Pollard
parent c63e199100
commit c62b09167c
14 changed files with 447 additions and 35 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectTasksOptions"> <component name="ProjectTasksOptions">
<TaskOptions isEnabled="true"> <TaskOptions isEnabled="false">
<option name="arguments" value="fmt $FilePath$" /> <option name="arguments" value="fmt $FilePath$" />
<option name="checkSyntaxErrors" value="true" /> <option name="checkSyntaxErrors" value="true" />
<option name="description" /> <option name="description" />
+2 -1
View File
@@ -15,7 +15,8 @@
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",
"format": "prettier --check .", "format": "prettier --check .",
"format:fix": "prettier --write .", "format:fix": "prettier --write .",
"check": "tsc --noEmit" "check": "tsc --noEmit",
"backfill:reddit": "tsx src/bin/backfill-reddit.ts"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.0", "@cloudflare/vitest-pool-workers": "^0.8.0",
+237
View File
@@ -0,0 +1,237 @@
import { DateTime } from 'luxon';
import { Metric } from 'src/interfaces/metrics.interface';
import { InfluxMetricsPushProvider } from 'src/repositories/influx-metrics-provider.repository';
import { MetricsPushRepository } from 'src/repositories/metrics-push.repository';
interface RedditDataPoint {
timestamp: DateTime;
subscribers: number;
}
// Historical data points we know about
const KNOWN_DATA_POINTS: RedditDataPoint[] = [
{ timestamp: DateTime.fromSeconds(1649635200), subscribers: 2 },
{ timestamp: DateTime.fromSeconds(1660262400), subscribers: 21 },
{ timestamp: DateTime.fromSeconds(1661040000), subscribers: 26 },
{ timestamp: DateTime.fromSeconds(1661126400), subscribers: 27 },
{ timestamp: DateTime.fromSeconds(1662076800), subscribers: 38 },
{ timestamp: DateTime.fromSeconds(1665360000), subscribers: 59 },
{ timestamp: DateTime.fromSeconds(1667952000), subscribers: 87 },
{ timestamp: DateTime.fromSeconds(1669680000), subscribers: 111 },
{ timestamp: DateTime.fromSeconds(1671321600), subscribers: 124 },
{ timestamp: DateTime.fromSeconds(1672358400), subscribers: 137 },
{ timestamp: DateTime.fromSeconds(1673308800), subscribers: 160 },
{ timestamp: DateTime.fromSeconds(1674259200), subscribers: 180 },
{ timestamp: DateTime.fromSeconds(1676332800), subscribers: 210 },
{ timestamp: DateTime.fromSeconds(1677801600), subscribers: 236 },
{ timestamp: DateTime.fromSeconds(1678579200), subscribers: 246 },
{ timestamp: DateTime.fromSeconds(1679270400), subscribers: 254 },
{ timestamp: DateTime.fromSeconds(1680307200), subscribers: 281 },
{ timestamp: DateTime.fromSeconds(1680652800), subscribers: 299 },
{ timestamp: DateTime.fromSeconds(1681084800), subscribers: 319 },
{ timestamp: DateTime.fromSeconds(1681603200), subscribers: 335 },
{ timestamp: DateTime.fromSeconds(1682035200), subscribers: 354 },
{ timestamp: DateTime.fromSeconds(1682380800), subscribers: 368 },
{ timestamp: DateTime.fromSeconds(1682985600), subscribers: 389 },
{ timestamp: DateTime.fromSeconds(1683590400), subscribers: 410 },
{ timestamp: DateTime.fromSeconds(1684022400), subscribers: 424 },
{ timestamp: DateTime.fromSeconds(1684454400), subscribers: 444 },
{ timestamp: DateTime.fromSeconds(1684800000), subscribers: 474 },
{ timestamp: DateTime.fromSeconds(1685318400), subscribers: 500 },
{ timestamp: DateTime.fromSeconds(1685750400), subscribers: 539 },
{ timestamp: DateTime.fromSeconds(1686096000), subscribers: 567 },
{ timestamp: DateTime.fromSeconds(1686441600), subscribers: 585 },
{ timestamp: DateTime.fromSeconds(1687046400), subscribers: 590 },
{ timestamp: DateTime.fromSeconds(1687478400), subscribers: 615 },
{ timestamp: DateTime.fromSeconds(1687824000), subscribers: 643 },
{ timestamp: DateTime.fromSeconds(1688256000), subscribers: 670 },
{ timestamp: DateTime.fromSeconds(1688601600), subscribers: 695 },
{ timestamp: DateTime.fromSeconds(1688947200), subscribers: 722 },
{ timestamp: DateTime.fromSeconds(1689292800), subscribers: 748 },
{ timestamp: DateTime.fromSeconds(1689811200), subscribers: 782 },
{ timestamp: DateTime.fromSeconds(1690502400), subscribers: 836 },
{ timestamp: DateTime.fromSeconds(1690761600), subscribers: 871 },
{ timestamp: DateTime.fromSeconds(1691280000), subscribers: 908 },
{ timestamp: DateTime.fromSeconds(1691625600), subscribers: 927 },
{ timestamp: DateTime.fromSeconds(1692403200), subscribers: 978 },
{ timestamp: DateTime.fromSeconds(1692662400), subscribers: 1013 },
{ timestamp: DateTime.fromSeconds(1693267200), subscribers: 1056 },
{ timestamp: DateTime.fromSeconds(1693699200), subscribers: 1095 },
{ timestamp: DateTime.fromSeconds(1693958400), subscribers: 1118 },
{ timestamp: DateTime.fromSeconds(1694304000), subscribers: 1133 },
{ timestamp: DateTime.fromSeconds(1694649600), subscribers: 1153 },
{ timestamp: DateTime.fromSeconds(1694995200), subscribers: 1174 },
{ timestamp: DateTime.fromSeconds(1695340800), subscribers: 1217 },
{ timestamp: DateTime.fromSeconds(1695600000), subscribers: 1243 },
{ timestamp: DateTime.fromSeconds(1695945600), subscribers: 1281 },
{ timestamp: DateTime.fromSeconds(1696204800), subscribers: 1302 },
{ timestamp: DateTime.fromSeconds(1696550400), subscribers: 1355 },
{ timestamp: DateTime.fromSeconds(1696809600), subscribers: 1401 },
{ timestamp: DateTime.fromSeconds(1697241600), subscribers: 1453 },
{ timestamp: DateTime.fromSeconds(1697500800), subscribers: 1483 },
{ timestamp: DateTime.fromSeconds(1697760000), subscribers: 1503 },
{ timestamp: DateTime.fromSeconds(1698019200), subscribers: 1545 },
{ timestamp: DateTime.fromSeconds(1698451200), subscribers: 1597 },
{ timestamp: DateTime.fromSeconds(1698710400), subscribers: 1641 },
{ timestamp: DateTime.fromSeconds(1698969600), subscribers: 1697 },
{ timestamp: DateTime.fromSeconds(1699315200), subscribers: 1752 },
{ timestamp: DateTime.fromSeconds(1699660800), subscribers: 1788 },
{ timestamp: DateTime.fromSeconds(1699833600), subscribers: 1812 },
{ timestamp: DateTime.fromSeconds(1700092800), subscribers: 1875 },
{ timestamp: DateTime.fromSeconds(1700438400), subscribers: 1949 },
{ timestamp: DateTime.fromSeconds(1700784000), subscribers: 2016 },
{ timestamp: DateTime.fromSeconds(1701129600), subscribers: 2068 },
{ timestamp: DateTime.fromSeconds(1701475200), subscribers: 2136 },
{ timestamp: DateTime.fromSeconds(1701820800), subscribers: 2192 },
{ timestamp: DateTime.fromSeconds(1702166400), subscribers: 2251 },
{ timestamp: DateTime.fromSeconds(1702512000), subscribers: 2310 },
{ timestamp: DateTime.fromSeconds(1702684800), subscribers: 2353 },
{ timestamp: DateTime.fromSeconds(1703548800), subscribers: 2542 },
{ timestamp: DateTime.fromSeconds(1704575992), subscribers: 2826 },
// Gap in historical data
{ timestamp: DateTime.fromSeconds(1709528400), subscribers: 4000 },
{ timestamp: DateTime.fromSeconds(1712203200), subscribers: 5000 },
{ timestamp: DateTime.fromSeconds(1724403398), subscribers: 9476 },
{ timestamp: DateTime.fromSeconds(1727411726), subscribers: 10884 },
{ timestamp: DateTime.fromSeconds(1733279612), subscribers: 14362 },
{ timestamp: DateTime.fromSeconds(1734757200), subscribers: 16000 },
{ timestamp: DateTime.fromSeconds(1734878583), subscribers: 16167 },
{ timestamp: DateTime.fromSeconds(1735490966), subscribers: 17012 },
{ timestamp: DateTime.fromSeconds(1738040400), subscribers: 20000 },
{ timestamp: DateTime.fromSeconds(1739125889), subscribers: 20767 },
{ timestamp: DateTime.fromSeconds(1741468187), subscribers: 22532 },
{ timestamp: DateTime.fromSeconds(1741474683), subscribers: 22604 },
{ timestamp: DateTime.fromSeconds(1741826945), subscribers: 22829 },
{ timestamp: DateTime.fromSeconds(1743096105), subscribers: 23771 },
{ timestamp: DateTime.fromSeconds(1743900139), subscribers: 24353 },
{ timestamp: DateTime.fromSeconds(1745763306), subscribers: 25611 },
{ timestamp: DateTime.fromSeconds(1747627200), subscribers: 27000 },
{ timestamp: DateTime.fromSeconds(1748727093), subscribers: 27901 },
{ timestamp: DateTime.fromSeconds(1749960000), subscribers: 28756 },
{ timestamp: DateTime.fromSeconds(1752379200), subscribers: 30378 },
{ timestamp: DateTime.fromSeconds(1752653774), subscribers: 30480 },
{ timestamp: DateTime.fromSeconds(1753142400), subscribers: 30799 },
{ timestamp: DateTime.fromSeconds(1753574400), subscribers: 31199 },
{ timestamp: DateTime.fromSeconds(1753920000), subscribers: 31469 },
{ timestamp: DateTime.fromSeconds(1754352000), subscribers: 31776 },
{ timestamp: DateTime.fromSeconds(1754625600), subscribers: 32000 },
{ timestamp: DateTime.fromSeconds(1755216000), subscribers: 32547 },
{ timestamp: DateTime.fromSeconds(1755475200), subscribers: 32775 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 19)), subscribers: 32825 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 20)), subscribers: 32947 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 21)), subscribers: 33042 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 22)), subscribers: 33108 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 23)), subscribers: 33219 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 24)), subscribers: 33321 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 25)), subscribers: 33390 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 26)), subscribers: 33495 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 27)), subscribers: 33597 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 28)), subscribers: 33665 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 29)), subscribers: 33729 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 30)), subscribers: 33813 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 7, 31)), subscribers: 33889 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 1)), subscribers: 33977 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 2)), subscribers: 34105 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 3)), subscribers: 34204 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 4)), subscribers: 34289 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 5)), subscribers: 34369 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 6)), subscribers: 34426 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 7)), subscribers: 34482 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 8)), subscribers: 34554 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 9)), subscribers: 34648 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 10)), subscribers: 34712 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 11)), subscribers: 34792 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 12)), subscribers: 34870 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 13)), subscribers: 34954 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 14)), subscribers: 35061 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 15)), subscribers: 35182 },
{ timestamp: DateTime.fromJSDate(new Date(2025, 8, 16)), subscribers: 35326 },
];
class RedditBackfillService {
constructor(
private metricsRepository: MetricsPushRepository,
private influxProvider: InfluxMetricsPushProvider,
private env: string,
) {}
async backfillReddit(subreddit: string = 'immich') {
console.log(`Starting Reddit backfill for r/${subreddit}`);
// Delete existing time series
await this.deleteTimeSeries('immich_data_repository_reddit_subscriber_.*', this.env);
const sortedData = KNOWN_DATA_POINTS.sort((a, b) => a.timestamp.toMillis() - b.timestamp.toMillis());
console.log(`Processing ${sortedData.length} hardcoded data points`);
// Push metrics to InfluxDB
let count = 0;
for (const dataPoint of sortedData) {
count++;
const metric = new Metric('reddit_subscriber')
.setTimestamp(dataPoint.timestamp.toJSDate())
.intField('total', dataPoint.subscribers)
.addTag('environment', this.env);
this.metricsRepository.push(metric);
// Flush every 100 metrics
if (count % 100 === 0) {
await this.influxProvider.flush();
console.log(`Flushed ${count} metrics...`);
}
}
await this.influxProvider.flush();
console.log(`Reddit backfill complete. Total metrics pushed: ${count}`);
}
private async deleteTimeSeries(namePattern: string, environment: string) {
const url = new URL(`${process.env.VMETRICS_DATA_API_URL}/api/v1/admin/tsdb/delete_series`);
const match = `{__name__=~'${namePattern}',environment='${environment}'}`;
url.search = new URLSearchParams({ ['match[]']: match }).toString();
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.VMETRICS_DATA_ADMIN_TOKEN}`,
'Content-Type': 'application/json',
},
});
console.log(`Deleting time series ${match}`);
if (response.status !== 204) {
console.log(response.status);
throw new Error(`Failed to delete time series ${match}`);
}
}
}
// Main execution
const main = async () => {
if (
!process.env.VMETRICS_DATA_WRITE_TOKEN ||
!process.env.VMETRICS_DATA_API_URL ||
!process.env.VMETRICS_DATA_ADMIN_TOKEN
) {
throw new Error('Missing VMetrics API credentials');
}
if (!process.env.ENVIRONMENT) {
throw new Error('Missing environment');
}
const env = `${process.env.ENVIRONMENT}${process.env.TF_VAR_stage ? `_${process.env.TF_VAR_stage}` : ''}`;
const influxProvider = new InfluxMetricsPushProvider(
process.env.VMETRICS_DATA_API_URL,
process.env.VMETRICS_DATA_WRITE_TOKEN,
);
const metricsRepository = new MetricsPushRepository('immich_data_repository', {}, [influxProvider]);
const service = new RedditBackfillService(metricsRepository, influxProvider, env);
await service.backfillReddit('immich');
};
main()
.then(() => console.log('Reddit backfill complete'))
.catch(console.error);
+17 -3
View File
@@ -9,6 +9,7 @@ import { MetricsQueryRepository } from 'src/repositories/metrics-query.repositor
import { ApiWorker } from 'src/workers/api.worker'; import { ApiWorker } from 'src/workers/api.worker';
import { IngestApiWorker } from 'src/workers/ingest-api.worker'; import { IngestApiWorker } from 'src/workers/ingest-api.worker';
import { IngestProcessorWorker } from 'src/workers/ingest-processor.worker'; import { IngestProcessorWorker } from 'src/workers/ingest-processor.worker';
import { RedditIngestWorker } from 'src/workers/reddit-ingest.worker';
type FetchRequest = IRequest & Parameters<ExportedHandlerFetchHandler>[0]; type FetchRequest = IRequest & Parameters<ExportedHandlerFetchHandler>[0];
@@ -28,7 +29,7 @@ const newIngestApiWorker = (request: FetchRequest, env: WorkerEnv) => {
return new IngestApiWorker(queue); return new IngestApiWorker(queue);
}; };
const withSlug = (req: FetchRequest, env: WorkerEnv) => { const slugAuth = (req: FetchRequest, env: WorkerEnv) => {
const slug = env.SLUG; const slug = env.SLUG;
if (slug && req.params.slug !== slug) { if (slug && req.params.slug !== slug) {
return error(401, 'Unauthorized'); return error(401, 'Unauthorized');
@@ -40,8 +41,9 @@ const handleError = (err: Error) => {
}; };
const router = AutoRouter<FetchRequest, [WorkerEnv, ExecutionContext]>() const router = AutoRouter<FetchRequest, [WorkerEnv, ExecutionContext]>()
.get('/api/github', (...args) => newApiWorker(...args).getGithubReports()) .get('/api/github', (...args) => newApiWorker(...args).getGithubData())
.post('/ingest/github/:slug', withSlug, async (req, env) => .get('/api/reddit', (...args) => newApiWorker(...args).getRedditData())
.post('/ingest/github/:slug', slugAuth, async (req, env) =>
newIngestApiWorker(req, env) newIngestApiWorker(req, env)
.onGithubEvent(await req.json()) .onGithubEvent(await req.json())
.catch(handleError) .catch(handleError)
@@ -66,4 +68,16 @@ export default {
await influxProvider.flush(); await influxProvider.flush();
}, },
scheduled: async (event, env) => {
const influxProvider = new InfluxMetricsPushProvider(env.VMETRICS_DATA_API_URL, env.VMETRICS_DATA_WRITE_TOKEN);
const metricsRepository = new MetricsPushRepository('immich_data_repository', {}, [influxProvider]);
const redditWorker = new RedditIngestWorker(metricsRepository, asEnvTag(env));
try {
await redditWorker.fetchAndStoreCurrentMetrics('immich');
await influxProvider.flush();
} catch (error) {
console.error('Failed to fetch Reddit metrics:', error);
}
},
} satisfies ExportedHandler<WorkerEnv, QueueItem>; } satisfies ExportedHandler<WorkerEnv, QueueItem>;
@@ -3,4 +3,4 @@ export interface IMetricsQueryRepository {
} }
export type RangeQueryResponse = { values: [number, number][] }[]; export type RangeQueryResponse = { values: [number, number][] }[];
export type QueryMaxOverTimeArgs = { metricName: string; start: number; end: number; step: string }; export type QueryMaxOverTimeArgs = { metricName: string; start: number; end: number; step: string; labels: string[] };
@@ -20,9 +20,9 @@ export class MetricsQueryRepository implements IMetricsQueryRepository {
private environment: string, private environment: string,
) {} ) {}
async queryMaxOverTime({ metricName, start, end, step }: QueryMaxOverTimeArgs): Promise<RangeQueryResponse> { async queryMaxOverTime({ metricName, start, end, step, labels }: QueryMaxOverTimeArgs): Promise<RangeQueryResponse> {
const url = new URL(`${this.vmetricsApiUrl}/api/v1/query_range`); const url = new URL(`${this.vmetricsApiUrl}/api/v1/query_range`);
const query = `max(max_over_time(${metricName}{environment="${this.environment}", repository_name="immich"}[${step}]))`; const query = `interpolate(max(max_over_time(${metricName}{environment="${this.environment}"${labels.length > 0 ? ',' + labels.join(',') : ''}}[${step}])))`;
const params = { const params = {
query, query,
start: `${start}`, start: `${start}`,
@@ -0,0 +1,35 @@
import fetchRetry from 'fetch-retry';
const fetch = fetchRetry(globalThis.fetch, {
retries: 3,
retryDelay: 1000,
});
export interface RedditSubredditData {
subscribers: number;
}
export interface RedditAboutResponse {
data: RedditSubredditData;
}
export class RedditRepository {
private readonly userAgent = 'Mozilla/5.0 (compatible; ImmichDataBot/1.0)';
async getSubredditData(subreddit: string): Promise<RedditSubredditData> {
const url = `https://www.reddit.com/r/${subreddit}/about.json`;
const response = await fetch(url, {
headers: {
'User-Agent': this.userAgent,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch Reddit data: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as RedditAboutResponse;
return data.data;
}
}
+18 -8
View File
@@ -5,21 +5,31 @@ import { IMetricsQueryRepository } from 'src/interfaces/query-metrics.interface'
export class ApiWorker { export class ApiWorker {
constructor(private metricsRepository: IMetricsQueryRepository) {} constructor(private metricsRepository: IMetricsQueryRepository) {}
async getGithubReports() { async getGithubData() {
const metrics = { const labels = ['repository_name="immich"'];
issues: { metricName: 'immich_data_repository_issue_open_total' }, return this.asTimeSeries({
pullRequests: { metricName: 'immich_data_repository_pull_request_open_total' }, issues: { metricName: 'immich_data_repository_issue_open_total', labels },
stars: { metricName: 'immich_data_repository_star_total' }, pullRequests: { metricName: 'immich_data_repository_pull_request_open_total', labels },
discussions: { metricName: 'immich_data_repository_discussion_total' }, stars: { metricName: 'immich_data_repository_star_total', labels },
mergedPullRequests: { metricName: 'immich_data_repository_pull_request_merged_total' }, discussions: { metricName: 'immich_data_repository_discussion_total', labels },
}; mergedPullRequests: { metricName: 'immich_data_repository_pull_request_merged_total', labels },
});
}
async getRedditData() {
return this.asTimeSeries({
subscribers: { metricName: 'immich_data_repository_reddit_subscriber_total', labels: [] },
});
}
async asTimeSeries(metrics: Record<string, { metricName: string; labels: string[] }>) {
const promises = Object.entries(metrics).map(async ([key, value]) => { const promises = Object.entries(metrics).map(async ([key, value]) => {
const results = await this.metricsRepository.queryMaxOverTime({ const results = await this.metricsRepository.queryMaxOverTime({
metricName: value.metricName, metricName: value.metricName,
start: IMMICH_INCEPTION.toMillis(), start: IMMICH_INCEPTION.toMillis(),
end: DateTime.now().toMillis(), end: DateTime.now().toMillis(),
step: '1d', step: '1d',
labels: value.labels,
}); });
return [key, results[0]?.values ?? []]; return [key, results[0]?.values ?? []];
}); });
@@ -0,0 +1,28 @@
import { IMetricsPushRepository, Metric } from 'src/interfaces/metrics.interface';
import { RedditRepository } from 'src/repositories/reddit.repository';
export class RedditIngestWorker {
private redditRepository = new RedditRepository();
constructor(
private metricsRepository: IMetricsPushRepository,
private envTag: string,
) {}
async fetchAndStoreCurrentMetrics(subreddit: string = 'immich') {
try {
const data = await this.redditRepository.getSubredditData(subreddit);
const metric = new Metric('reddit_subscriber')
.intField('total', data.subscribers)
.addTag('environment', this.envTag);
this.metricsRepository.push(metric);
return data.subscribers;
} catch (error) {
console.error(`Failed to fetch Reddit data for r/${subreddit}:`, error);
throw error;
}
}
}
+3
View File
@@ -21,3 +21,6 @@ SLUG = "slug"
max_batch_timeout = 30 max_batch_timeout = 30
max_retries = 10 max_retries = 10
dead_letter_queue = "data-ingest-dev-dlq" dead_letter_queue = "data-ingest-dev-dlq"
[triggers]
crons = ["0 */6 * * *"]
@@ -136,6 +136,45 @@ resource "cloudflare_workers_script" "data_ingest_processor" {
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
} }
resource "cloudflare_workers_script" "data_ingest_cron" {
account_id = var.cloudflare_account_id
name = "data-ingest-cron${local.resource_suffix}"
content = file("${var.dist_dir}/backend/index.js")
module = true
plain_text_binding {
name = "ENVIRONMENT"
text = var.env
}
dynamic "plain_text_binding" {
for_each = var.stage != "" ? [var.stage] : []
content {
name = "STAGE"
text = var.stage
}
}
plain_text_binding {
name = "VMETRICS_DATA_API_URL"
text = local.vmetrics_data_api_url
}
secret_text_binding {
name = "VMETRICS_DATA_WRITE_TOKEN"
text = var.vmetrics_data_write_token
}
compatibility_date = "2025-09-17"
compatibility_flags = ["nodejs_compat"]
}
resource "cloudflare_workers_cron_trigger" "data_ingest_cron" {
account_id = var.cloudflare_account_id
script_name = cloudflare_workers_script.data_ingest_cron.name
schedules = [ "*/5 * * * *" ]
}
locals { locals {
data_api_url = "${local.domain}/api" data_api_url = "${local.domain}/api"
ingest_api_url = "${local.domain}/ingest" ingest_api_url = "${local.domain}/ingest"
@@ -5,7 +5,7 @@
import uPlot, { type Axis } from 'uplot'; import uPlot, { type Axis } from 'uplot';
type DataRecord = [number, number]; type DataRecord = [number, number];
type Colors = 'yellow' | 'green' | 'purple' | 'blue'; type Colors = 'yellow' | 'green' | 'purple' | 'blue' | 'orange';
type Props = { type Props = {
id: string; id: string;
@@ -32,6 +32,7 @@
green: 'rgb(70, 183, 83)', green: 'rgb(70, 183, 83)',
purple: 'rgb(172, 124, 249)', purple: 'rgb(172, 124, 249)',
blue: 'rgb(63, 106, 222)', blue: 'rgb(63, 106, 222)',
orange: 'rgb(255, 69, 0)',
}; };
let areaColor = { let areaColor = {
@@ -39,6 +40,7 @@
green: 'rgba(70, 183, 83, 0.1)', green: 'rgba(70, 183, 83, 0.1)',
purple: 'rgba(172, 124, 249, 0.1)', purple: 'rgba(172, 124, 249, 0.1)',
blue: 'rgba(63, 106, 222, 0.1)', blue: 'rgba(63, 106, 222, 0.1)',
orange: 'rgba(255, 69, 0, 0.1)',
}; };
const axis: Axis = { const axis: Axis = {
+26 -12
View File
@@ -1,18 +1,18 @@
type GithubDataResponse = { type GithubDataResponse = {
issues: GithubReportData; issues: ReportData;
pullRequests: GithubReportData; pullRequests: ReportData;
stars: GithubReportData; stars: ReportData;
discussions: GithubReportData; discussions: ReportData;
mergedPullRequests: GithubReportData; mergedPullRequests: ReportData;
}; };
type GithubReportData = Array<[number, number]>; type ReportData = Array<[number, number]>;
type GithubDataRecord = [number, number]; type DataRecord = [number, number];
export const githubStars = $state<{ value?: GithubDataRecord[] }>({}); export const githubStars = $state<{ value?: DataRecord[] }>({});
export const githubIssues = $state<{ value?: GithubDataRecord[] }>({}); export const githubIssues = $state<{ value?: DataRecord[] }>({});
export const githubPullRequests = $state<{ value?: GithubDataRecord[] }>({}); export const githubPullRequests = $state<{ value?: DataRecord[] }>({});
export const githubDiscussions = $state<{ value?: GithubDataRecord[] }>({}); export const githubDiscussions = $state<{ value?: DataRecord[] }>({});
export const githubMergedPullRequests = $state<{ value?: GithubDataRecord[] }>({}); export const githubMergedPullRequests = $state<{ value?: DataRecord[] }>({});
export const loadGithubData = async () => { export const loadGithubData = async () => {
const response = await fetch('/api/github'); const response = await fetch('/api/github');
@@ -27,3 +27,17 @@ export const loadGithubData = async () => {
githubMergedPullRequests.value = mergedPullRequests; githubMergedPullRequests.value = mergedPullRequests;
} }
}; };
type RedditDataResponse = {
subscribers: ReportData;
};
export const redditMembers = $state<{ value?: DataRecord[] }>({});
export const loadRedditData = async () => {
const response = await fetch('/api/reddit');
if (response.ok) {
const { subscribers } = (await response.json()) as RedditDataResponse;
redditMembers.value = subscribers;
}
};
+34 -5
View File
@@ -8,6 +8,8 @@
githubStars, githubStars,
githubMergedPullRequests, githubMergedPullRequests,
loadGithubData, loadGithubData,
loadRedditData,
redditMembers,
} from '$lib/services/api.svelte'; } from '$lib/services/api.svelte';
import { import {
Card, Card,
@@ -22,7 +24,7 @@
Stack, Stack,
Text, Text,
} from '@immich/ui'; } from '@immich/ui';
import { mdiBugOutline, mdiMessageOutline, mdiSourceBranch, mdiStarOutline } from '@mdi/js'; import { mdiAccountGroup, mdiBugOutline, mdiMessageOutline, mdiSourceBranch, mdiStarOutline } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import uPlot from 'uplot'; import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css'; import 'uplot/dist/uPlot.min.css';
@@ -42,6 +44,7 @@
const futo = DateTime.fromObject({ year: 2024, month: 5, day: 1 }, { zone: 'America/Chicago' }); const futo = DateTime.fromObject({ year: 2024, month: 5, day: 1 }, { zone: 'America/Chicago' });
void loadGithubData(); void loadGithubData();
void loadRedditData();
</script> </script>
<Container size="giant" center> <Container size="giant" center>
@@ -49,7 +52,7 @@
<Heading size="title">Immich Data</Heading> <Heading size="title">Immich Data</Heading>
<section class="flex flex-col gap-4"> <section class="flex flex-col gap-4">
<Heading size="large" class="flex gap-1 items-center mt-4" tag="h1">Statistics</Heading> <Heading size="large" tag="h1">Statistics</Heading>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
<Card color="secondary"> <Card color="secondary">
<CardHeader class="text-center"> <CardHeader class="text-center">
@@ -101,9 +104,7 @@
<section class="flex flex-col gap-4"> <section class="flex flex-col gap-4">
<div> <div>
<Heading size="large" class="flex gap-1 items-center" tag="h1"> <Heading size="large" tag="h1">GitHub</Heading>
<span>GitHub Trends</span>
</Heading>
<Text color="muted">immich-app/immich</Text> <Text color="muted">immich-app/immich</Text>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@@ -192,5 +193,33 @@
</Card> </Card>
</div> </div>
</section> </section>
<section class="flex flex-col gap-4">
<div>
<Heading size="large" tag="h1">Reddit</Heading>
<Text color="muted">r/immich</Text>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card color="secondary">
<CardHeader>
<CardTitle>
<HStack>
<Icon icon={mdiAccountGroup} />
<span>Total Members</span>
</HStack>
</CardTitle>
</CardHeader>
<CardBody>
<GithubGraph
color="orange"
id="reddit-members"
data={redditMembers.value}
{cursorOpts}
label="Reddit members"
/>
</CardBody>
</Card>
</div>
</section>
</Stack> </Stack>
</Container> </Container>