Skip to content

Commit 08bcc91

Browse files
author
Your Name
committed
feat: add news cache metrics
1 parent e49bada commit 08bcc91

3 files changed

Lines changed: 58 additions & 4 deletions

File tree

src/metrics/register.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Registry, Counter, collectDefaultMetrics } from "prom-client";
1+
import { Registry, Counter, Histogram, collectDefaultMetrics } from "prom-client";
22

33
export const register = new Registry();
44

@@ -10,3 +10,25 @@ export const httpRequestsTotal = new Counter({
1010
labelNames: ["method", "status_code"],
1111
registers: [register],
1212
});
13+
14+
export const cacheEventsTotal = new Counter({
15+
name: "news_cache_events_total",
16+
help: "Total news search cache lookups by result",
17+
labelNames: ["result"],
18+
registers: [register],
19+
});
20+
21+
export const upstreamRequestsTotal = new Counter({
22+
name: "news_upstream_requests_total",
23+
help: "Total upstream news provider requests by outcome",
24+
labelNames: ["outcome"],
25+
registers: [register],
26+
});
27+
28+
export const upstreamRequestDurationSeconds = new Histogram({
29+
name: "news_upstream_request_duration_seconds",
30+
help: "Duration of upstream news provider requests in seconds",
31+
labelNames: ["outcome"],
32+
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15],
33+
registers: [register],
34+
});

src/services/newsService.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { HttpError } from "../errors/HttpError";
44
import { getCacheStore } from "../cache/store";
55
import { UPSTREAM_TIMEOUT_MS } from "../config/upstream";
66
import { ArticleSearchFilters, ArticleSearchOptions } from "../types/search";
7+
import {
8+
cacheEventsTotal,
9+
upstreamRequestDurationSeconds,
10+
upstreamRequestsTotal,
11+
} from "../metrics/register";
712

813
const API_KEY = process.env.GNEWS_API_KEY;
914
const BASE_URL = "https://gnews.io/api/v4";
@@ -50,25 +55,36 @@ export const fetchArticles = async (options: ArticleSearchOptions): Promise<Arti
5055
const store = getCacheStore();
5156
const cached = await store.get(cacheKey);
5257
if (cached) {
58+
cacheEventsTotal.inc({ result: "hit" });
5359
return cached as Article[];
5460
}
61+
cacheEventsTotal.inc({ result: "miss" });
5562

63+
const stopUpstreamTimer = upstreamRequestDurationSeconds.startTimer();
64+
let articles: Article[];
5665
try {
5766
const response = await axios.get<{ articles: Article[] }>(`${BASE_URL}/search`, {
5867
params: toProviderParams(options),
5968
timeout: UPSTREAM_TIMEOUT_MS,
6069
validateStatus: (s) => s >= 200 && s < 300,
6170
});
6271

63-
const articles = normalizeArticles(response.data);
64-
await store.set(cacheKey, articles);
65-
return articles;
72+
articles = normalizeArticles(response.data);
73+
upstreamRequestsTotal.inc({ outcome: "success" });
74+
stopUpstreamTimer({ outcome: "success" });
6675
} catch (err) {
76+
const outcome =
77+
err instanceof HttpError && err.statusCode === 502 ? "invalid_payload" : "error";
78+
upstreamRequestsTotal.inc({ outcome });
79+
stopUpstreamTimer({ outcome });
6780
if (axios.isAxiosError(err)) {
6881
throw new HttpError(502, "Upstream news service unavailable");
6982
}
7083
throw err;
7184
}
85+
86+
await store.set(cacheKey, articles);
87+
return articles;
7288
};
7389

7490
export const fetchArticlesByTitle = async (title: string): Promise<Article | undefined> => {

test/app.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,22 @@ describe("app", () => {
135135
expect(mockGet).toHaveBeenCalledTimes(1);
136136
});
137137

138+
it("records cache and upstream metrics for article searches", async () => {
139+
mockGet.mockResolvedValue({ data: { articles: sampleArticles } });
140+
const q = `metrics-${Math.random().toString(36).slice(2)}`;
141+
await request(app).get(`/api/articles?query=${encodeURIComponent(q)}&count=2`);
142+
await request(app).get(`/api/articles?query=${encodeURIComponent(q)}&count=2`);
143+
144+
const res = await request(app).get("/metrics");
145+
expect(res.status).toBe(200);
146+
expect(res.text).toContain('news_cache_events_total{result="miss"}');
147+
expect(res.text).toContain('news_cache_events_total{result="hit"}');
148+
expect(res.text).toContain('news_upstream_requests_total{outcome="success"}');
149+
expect(res.text).toContain(
150+
'news_upstream_request_duration_seconds_bucket{le="0.05",outcome="success"}'
151+
);
152+
});
153+
138154
it("GET /api/articles/title returns article when matched", async () => {
139155
mockGet.mockResolvedValueOnce({ data: { articles: sampleArticles } });
140156
const title = encodeURIComponent("Alpha headline");

0 commit comments

Comments
 (0)