A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules

feat: Add post label tracking with Redis and metrics

This commit introduces a new feature to track labeled posts using Redis
and trigger account-level actions when thresholds are met. It includes
the following changes:

- Added Redis configuration to `.env.example` and the `compose.yaml`
file to run Redis. - Implemented post tracking logic using Redis. -
Added functionality to apply account labels, report accounts, and
comment on accounts based on label thresholds. - Added example
configuration files (`tracked-labels.json` and
`tracked-labels.example.json`). - Implemented Prometheus metrics for
monitoring label tracking and account actions. - Added a health check
endpoint. - Updated `README.md` with instructions on how to use the new
feature.

Skywatch af1cc6bf dca36de4

+4
.env.example
··· 11 11 CURSOR_UPDATE_INTERVAL=10000 12 12 LABEL_LIMIT=2900 * 1000 13 13 LABEL_LIMIT_WAIT=300 * 1000 14 + 15 + # Redis configuration for post label tracking 16 + # Used to track labeled posts and trigger account-level actions when thresholds are met 17 + REDIS_URL=redis://localhost:6379
+142 -3
README.md
··· 16 16 bun run start 17 17 ``` 18 18 19 - To run in docker: 19 + To run with Docker Compose (recommended): 20 + 21 + ```bash 22 + # Create required files 23 + touch cursor.txt 24 + cp .env.example .env 25 + # Edit .env with your values 26 + 27 + # Start services (automod + Redis) 28 + docker compose up -d 29 + 30 + # View logs 31 + docker compose logs -f 32 + 33 + # Stop services 34 + docker compose down 35 + ``` 36 + 37 + To run standalone Docker container (you'll need to provide your own Redis): 20 38 21 39 ```bash 22 - docker build -pull -t skywatch-tools . 23 - docker run -d -p 4101:4101 skywatch-autolabeler 40 + docker build -t skywatch-tools . 41 + docker run -d \ 42 + -p 4101:4101 \ 43 + -e REDIS_URL=redis://your-redis-host:6379 \ 44 + --env-file .env \ 45 + -v $(pwd)/cursor.txt:/app/cursor.txt \ 46 + skywatch-tools 24 47 ``` 25 48 26 49 ## Brief overview ··· 30 53 In certain cases, where regexp will create too many false positives, it will flag content as a report against related to the account, so that it can be reviewed later. 31 54 32 55 For information on how to set-up your own checks, please see the [developing_checks.md](./src/developing_checks.md) file. 56 + 57 + ## Post Label Tracking 58 + 59 + The system includes automated tracking of labeled posts to detect repeat offenders. When configured labels accumulate on posts from the same account, the system automatically applies account-level actions. 60 + 61 + ### Configuration 62 + 63 + **Redis Setup** 64 + 65 + Redis is required for tracking labeled posts. Set the `REDIS_URL` environment variable: 66 + 67 + ```bash 68 + REDIS_URL=redis://localhost:6379 69 + ``` 70 + 71 + **Tracked Labels Configuration** 72 + 73 + Create `tracked-labels.json` in the project root to configure which labels to track: 74 + 75 + ```json 76 + [ 77 + { 78 + "label": "spam", 79 + "threshold": 5, 80 + "accountLabel": "repeat-spammer", 81 + "accountComment": "Account has posted spam content multiple times", 82 + "windowDays": 30, 83 + "reportAcct": true, 84 + "commentAcct": false 85 + } 86 + ] 87 + ``` 88 + 89 + **Configuration Fields:** 90 + 91 + - `label` (required): The post label to track 92 + - `threshold` (required): Number of labeled posts before triggering account action 93 + - `accountLabel` (required): Label to apply to the account when threshold is met 94 + - `accountComment` (required): Comment text for account reports/comments 95 + - `windowDays` (optional): Only count posts within this many days (omit for all-time tracking) 96 + - `reportAcct` (optional): Whether to report the account when threshold is met (default: false) 97 + - `commentAcct` (optional): Whether to add a comment to the account when threshold is met (default: false) 98 + 99 + See `tracked-labels.example.json` for complete examples. 100 + 101 + ### How It Works 102 + 103 + 1. When a post is labeled with a tracked label, the system records it in Redis 104 + 2. The system maintains a count of labeled posts per account per label 105 + 3. When the threshold is reached, the system automatically: 106 + - Labels the account with the configured `accountLabel` 107 + - Optionally reports the account (if `reportAcct: true`) 108 + - Optionally adds a comment (if `commentAcct: true`) 109 + 4. If `windowDays` is set, only posts within that time window are counted 110 + 111 + ### Data Storage 112 + 113 + - Redis keys follow the pattern: `post-labels:{did}:{label}` 114 + - Post URIs are stored in sorted sets with timestamps as scores 115 + - Keys automatically expire after 30 days (sliding window) 116 + - Old posts are pruned based on `windowDays` configuration 117 + 118 + ## Monitoring and Observability 119 + 120 + The system exposes Prometheus metrics and health check endpoints for monitoring. 121 + 122 + ### Metrics Endpoint 123 + 124 + Available at `http://localhost:4001/metrics` (configurable via `METRICS_PORT` environment variable) 125 + 126 + **Custom Metrics:** 127 + 128 + - `posts_tracked_total` - Counter for posts tracked by label type 129 + - Labels: `label_type` 130 + 131 + - `thresholds_met_total` - Counter for label thresholds met 132 + - Labels: `label_type`, `account_label` 133 + 134 + - `account_actions_triggered_total` - Counter for account-level actions triggered 135 + - Labels: `action_type` (label|report|comment), `label_type`, `success` (true|false) 136 + 137 + - `redis_connected` - Gauge for Redis connection status (1=connected, 0=disconnected) 138 + 139 + **Example Prometheus queries:** 140 + 141 + ```promql 142 + # Rate of posts being tracked per label 143 + rate(posts_tracked_total[5m]) 144 + 145 + # How many times each threshold has been met 146 + thresholds_met_total 147 + 148 + # Success rate of account labeling 149 + sum(rate(account_actions_triggered_total{action_type="label",success="true"}[5m])) / sum(rate(account_actions_triggered_total{action_type="label"}[5m])) 150 + ``` 151 + 152 + ### Health Check Endpoint 153 + 154 + Available at `http://localhost:4001/health` 155 + 156 + Returns HTTP 200 when healthy, HTTP 503 when unhealthy. 157 + 158 + **Response format:** 159 + 160 + ```json 161 + { 162 + "status": "healthy", 163 + "redis": "connected", 164 + "timestamp": "2025-01-18T12:34:56.789Z" 165 + } 166 + ``` 167 + 168 + Use this endpoint for: 169 + - Kubernetes liveness/readiness probes 170 + - Load balancer health checks 171 + - Uptime monitoring systems
+36
compose.yaml
··· 9 9 version: "3.8" 10 10 11 11 services: 12 + redis: 13 + image: redis:7-alpine 14 + container_name: skywatch-redis 15 + restart: unless-stopped 16 + 17 + # Expose Redis port for debugging (optional, remove in production) 18 + ports: 19 + - "6379:6379" 20 + 21 + # Persist Redis data 22 + volumes: 23 + - redis-data:/data 24 + 25 + # Redis configuration 26 + command: redis-server --appendonly yes --appendfsync everysec 27 + 28 + # Health check 29 + healthcheck: 30 + test: ["CMD", "redis-cli", "ping"] 31 + interval: 10s 32 + timeout: 3s 33 + retries: 3 34 + start_period: 10s 35 + 12 36 automod: 13 37 # Build the Docker image from the Dockerfile in the current directory. 14 38 build: . ··· 17 41 # Restart the container automatically if it stops unexpectedly. 18 42 restart: unless-stopped 19 43 44 + # Wait for Redis to be healthy before starting 45 + depends_on: 46 + redis: 47 + condition: service_healthy 48 + 20 49 # Expose the metrics server port to the host machine. 21 50 ports: 22 51 - "4100:4101" ··· 26 55 env_file: 27 56 - .env 28 57 58 + # Override REDIS_URL to use the Redis service 59 + environment: 60 + - REDIS_URL=redis://redis:6379 61 + 29 62 # Mount a volume to persist the firehose cursor. 30 63 # This links the `cursor.txt` file from your host into the container at `/app/cursor.txt`. 31 64 # Persisting this file allows the automod to resume from where it left off 32 65 # after a restart, preventing it from reprocessing old events or skipping new ones. 33 66 volumes: 34 67 - ./cursor.txt:/app/cursor.txt 68 + 69 + volumes: 70 + redis-data:
+10 -1
src/main.ts
··· 23 23 } from "./rules/profiles/checkProfiles.js"; 24 24 import { checkFacetSpam } from "./rules/facets/facets.js"; 25 25 import { checkAccountAge } from "./rules/account/age.js"; 26 + import { connectRedis, redis } from "./redis.js"; 27 + 28 + // Initialize Redis connection for post label tracking 29 + connectRedis(); 26 30 27 31 let cursor = 0; 28 32 let cursorUpdateInterval: NodeJS.Timeout; ··· 324 328 325 329 jetstream.start(); 326 330 327 - function shutdown() { 331 + async function shutdown() { 328 332 try { 329 333 logger.info({ process: "MAIN" }, "Shutting down gracefully"); 330 334 fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8"); 331 335 jetstream.close(); 332 336 metricsServer.close(); 337 + 338 + // Close Redis connection 339 + logger.info({ process: "MAIN" }, "Closing Redis connection"); 340 + await redis.quit(); 341 + logger.info({ process: "MAIN" }, "Redis connection closed"); 333 342 } catch (error) { 334 343 logger.error({ process: "MAIN", error }, "Error shutting down gracefully"); 335 344 process.exit(1);
+44 -1
src/metrics.ts
··· 1 1 import express from "express"; 2 - import { Registry, collectDefaultMetrics } from "prom-client"; 2 + import { Registry, collectDefaultMetrics, Counter, Gauge } from "prom-client"; 3 3 4 4 import { logger } from "./logger.js"; 5 + import { isRedisConnected } from "./redis.js"; 5 6 6 7 const register = new Registry(); 7 8 collectDefaultMetrics({ register }); 8 9 10 + export const postsTrackedCounter = new Counter({ 11 + name: "posts_tracked_total", 12 + help: "Total number of posts tracked for label threshold monitoring", 13 + labelNames: ["label_type"], 14 + registers: [register], 15 + }); 16 + 17 + export const thresholdsMetCounter = new Counter({ 18 + name: "thresholds_met_total", 19 + help: "Total number of times label thresholds have been met", 20 + labelNames: ["label_type", "account_label"], 21 + registers: [register], 22 + }); 23 + 24 + export const accountActionsCounter = new Counter({ 25 + name: "account_actions_triggered_total", 26 + help: "Total number of account-level actions triggered", 27 + labelNames: ["action_type", "label_type", "success"], 28 + registers: [register], 29 + }); 30 + 31 + export const redisConnectedGauge = new Gauge({ 32 + name: "redis_connected", 33 + help: "Redis connection status (1 = connected, 0 = disconnected)", 34 + registers: [register], 35 + async collect() { 36 + this.set(isRedisConnected() ? 1 : 0); 37 + }, 38 + }); 39 + 9 40 const app = express(); 10 41 11 42 app.get("/metrics", (req, res) => { ··· 19 50 logger.error({ process: "METRICS", error: (ex as Error).message }, "Error serving metrics"); 20 51 res.status(500).end((ex as Error).message); 21 52 }); 53 + }); 54 + 55 + app.get("/health", (req, res) => { 56 + const redisConnected = isRedisConnected(); 57 + const status = redisConnected ? "healthy" : "unhealthy"; 58 + const statusCode = redisConnected ? 200 : 503; 59 + 60 + res.status(statusCode).json({ 61 + status, 62 + redis: redisConnected ? "connected" : "disconnected", 63 + timestamp: new Date().toISOString(), 64 + }); 22 65 }); 23 66 24 67 export const startMetricsServer = (port: number, host = "127.0.0.1") => {
+70
src/tests/trackPostLabel.test.ts
··· 34 34 addPostAndCheckThreshold: vi.fn(), 35 35 })); 36 36 37 + vi.mock("../metrics.js", () => ({ 38 + postsTrackedCounter: { 39 + inc: vi.fn(), 40 + }, 41 + thresholdsMetCounter: { 42 + inc: vi.fn(), 43 + }, 44 + })); 45 + 37 46 // Now import after mocks are set up 38 47 import { trackPostLabel } from "../trackPostLabel.js"; 39 48 import { logger } from "../logger.js"; 40 49 import { addPostAndCheckThreshold } from "../redis.js"; 50 + import { postsTrackedCounter, thresholdsMetCounter } from "../metrics.js"; 41 51 42 52 describe("trackPostLabel", () => { 43 53 const testDid = "did:plc:test123"; ··· 275 285 testAtURI, 276 286 expect.any(Object), 277 287 ); 288 + }); 289 + }); 290 + 291 + describe("metrics tracking", () => { 292 + it("should increment postsTrackedCounter when post is tracked", async () => { 293 + vi.mocked(addPostAndCheckThreshold).mockResolvedValue(2); 294 + 295 + await trackPostLabel(testDid, testAtURI, "spam"); 296 + 297 + expect(postsTrackedCounter.inc).toHaveBeenCalledWith({ 298 + label_type: "spam", 299 + }); 300 + }); 301 + 302 + it("should increment postsTrackedCounter for different labels", async () => { 303 + vi.mocked(addPostAndCheckThreshold).mockResolvedValue(1); 304 + 305 + await trackPostLabel(testDid, testAtURI, "scam"); 306 + 307 + expect(postsTrackedCounter.inc).toHaveBeenCalledWith({ 308 + label_type: "scam", 309 + }); 310 + }); 311 + 312 + it("should increment thresholdsMetCounter when threshold is met", async () => { 313 + vi.mocked(addPostAndCheckThreshold).mockResolvedValue(5); 314 + 315 + await trackPostLabel(testDid, testAtURI, "spam"); 316 + 317 + expect(thresholdsMetCounter.inc).toHaveBeenCalledWith({ 318 + label_type: "spam", 319 + account_label: "repeat-spammer", 320 + }); 321 + }); 322 + 323 + it("should not increment thresholdsMetCounter when below threshold", async () => { 324 + vi.mocked(addPostAndCheckThreshold).mockResolvedValue(3); 325 + 326 + await trackPostLabel(testDid, testAtURI, "spam"); 327 + 328 + expect(postsTrackedCounter.inc).toHaveBeenCalled(); 329 + expect(thresholdsMetCounter.inc).not.toHaveBeenCalled(); 330 + }); 331 + 332 + it("should not increment metrics for untracked labels", async () => { 333 + await trackPostLabel(testDid, testAtURI, "untracked"); 334 + 335 + expect(postsTrackedCounter.inc).not.toHaveBeenCalled(); 336 + expect(thresholdsMetCounter.inc).not.toHaveBeenCalled(); 337 + }); 338 + 339 + it("should not increment metrics on error", async () => { 340 + vi.mocked(addPostAndCheckThreshold).mockRejectedValue( 341 + new Error("Redis error"), 342 + ); 343 + 344 + await trackPostLabel(testDid, testAtURI, "spam"); 345 + 346 + expect(postsTrackedCounter.inc).not.toHaveBeenCalled(); 347 + expect(thresholdsMetCounter.inc).not.toHaveBeenCalled(); 278 348 }); 279 349 }); 280 350 });
+215
src/tests/triggerAccountLabel.test.ts
··· 16 16 createAccountComment: vi.fn(), 17 17 })); 18 18 19 + vi.mock("../metrics.js", () => ({ 20 + accountActionsCounter: { 21 + inc: vi.fn(), 22 + }, 23 + })); 24 + 19 25 // Import after mocks 20 26 import { triggerAccountLabel } from "../triggerAccountLabel.js"; 21 27 import { logger } from "../logger.js"; ··· 24 30 createAccountReport, 25 31 createAccountComment, 26 32 } from "../moderation.js"; 33 + import { accountActionsCounter } from "../metrics.js"; 27 34 28 35 describe("triggerAccountLabel", () => { 29 36 const testDid = "did:plc:test123"; ··· 448 455 }), 449 456 expect.any(String), 450 457 ); 458 + }); 459 + }); 460 + 461 + describe("metrics tracking", () => { 462 + it("should increment accountActionsCounter for successful label", async () => { 463 + const action: AccountLabelAction = { 464 + type: "label-account", 465 + did: testDid, 466 + config: { 467 + label: "spam", 468 + threshold: 5, 469 + accountLabel: "repeat-spammer", 470 + accountComment: "Test", 471 + }, 472 + currentCount: 5, 473 + }; 474 + 475 + vi.mocked(createAccountLabel).mockResolvedValue(undefined); 476 + 477 + await triggerAccountLabel(action); 478 + 479 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 480 + action_type: "label", 481 + label_type: "spam", 482 + success: "true", 483 + }); 484 + }); 485 + 486 + it("should increment accountActionsCounter for successful report", async () => { 487 + const action: AccountLabelAction = { 488 + type: "label-account", 489 + did: testDid, 490 + config: { 491 + label: "scam", 492 + threshold: 3, 493 + accountLabel: "repeat-scammer", 494 + accountComment: "Test", 495 + reportAcct: true, 496 + }, 497 + currentCount: 3, 498 + }; 499 + 500 + vi.mocked(createAccountLabel).mockResolvedValue(undefined); 501 + vi.mocked(createAccountReport).mockResolvedValue(undefined); 502 + 503 + await triggerAccountLabel(action); 504 + 505 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 506 + action_type: "label", 507 + label_type: "scam", 508 + success: "true", 509 + }); 510 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 511 + action_type: "report", 512 + label_type: "scam", 513 + success: "true", 514 + }); 515 + }); 516 + 517 + it("should increment accountActionsCounter for successful comment", async () => { 518 + const action: AccountLabelAction = { 519 + type: "label-account", 520 + did: testDid, 521 + config: { 522 + label: "misinformation", 523 + threshold: 10, 524 + accountLabel: "frequent-misinfo", 525 + accountComment: "Test", 526 + commentAcct: true, 527 + }, 528 + currentCount: 10, 529 + }; 530 + 531 + vi.mocked(createAccountLabel).mockResolvedValue(undefined); 532 + vi.mocked(createAccountComment).mockResolvedValue(undefined); 533 + 534 + await triggerAccountLabel(action); 535 + 536 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 537 + action_type: "label", 538 + label_type: "misinformation", 539 + success: "true", 540 + }); 541 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 542 + action_type: "comment", 543 + label_type: "misinformation", 544 + success: "true", 545 + }); 546 + }); 547 + 548 + it("should increment all three metrics for full config", async () => { 549 + const action: AccountLabelAction = { 550 + type: "label-account", 551 + did: testDid, 552 + config: { 553 + label: "harassment", 554 + threshold: 2, 555 + accountLabel: "repeat-harasser", 556 + accountComment: "Test", 557 + reportAcct: true, 558 + commentAcct: true, 559 + }, 560 + currentCount: 2, 561 + }; 562 + 563 + vi.mocked(createAccountLabel).mockResolvedValue(undefined); 564 + vi.mocked(createAccountReport).mockResolvedValue(undefined); 565 + vi.mocked(createAccountComment).mockResolvedValue(undefined); 566 + 567 + await triggerAccountLabel(action); 568 + 569 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 570 + action_type: "label", 571 + label_type: "harassment", 572 + success: "true", 573 + }); 574 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 575 + action_type: "report", 576 + label_type: "harassment", 577 + success: "true", 578 + }); 579 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 580 + action_type: "comment", 581 + label_type: "harassment", 582 + success: "true", 583 + }); 584 + }); 585 + 586 + it("should increment failure metric when label fails", async () => { 587 + const action: AccountLabelAction = { 588 + type: "label-account", 589 + did: testDid, 590 + config: { 591 + label: "spam", 592 + threshold: 5, 593 + accountLabel: "repeat-spammer", 594 + accountComment: "Test", 595 + }, 596 + currentCount: 5, 597 + }; 598 + 599 + vi.mocked(createAccountLabel).mockRejectedValue(new Error("Label failed")); 600 + 601 + await triggerAccountLabel(action); 602 + 603 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 604 + action_type: "label", 605 + label_type: "spam", 606 + success: "false", 607 + }); 608 + }); 609 + 610 + it("should increment failure metric when report fails after successful label", async () => { 611 + const action: AccountLabelAction = { 612 + type: "label-account", 613 + did: testDid, 614 + config: { 615 + label: "scam", 616 + threshold: 3, 617 + accountLabel: "repeat-scammer", 618 + accountComment: "Test", 619 + reportAcct: true, 620 + }, 621 + currentCount: 3, 622 + }; 623 + 624 + vi.mocked(createAccountLabel).mockResolvedValue(undefined); 625 + vi.mocked(createAccountReport).mockRejectedValue(new Error("Report failed")); 626 + 627 + await triggerAccountLabel(action); 628 + 629 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 630 + action_type: "label", 631 + label_type: "scam", 632 + success: "true", 633 + }); 634 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 635 + action_type: "report_or_comment", 636 + label_type: "scam", 637 + success: "false", 638 + }); 639 + }); 640 + 641 + it("should not increment metrics when reportAcct is false", async () => { 642 + const action: AccountLabelAction = { 643 + type: "label-account", 644 + did: testDid, 645 + config: { 646 + label: "spam", 647 + threshold: 5, 648 + accountLabel: "repeat-spammer", 649 + accountComment: "Test", 650 + reportAcct: false, 651 + }, 652 + currentCount: 5, 653 + }; 654 + 655 + vi.mocked(createAccountLabel).mockResolvedValue(undefined); 656 + 657 + await triggerAccountLabel(action); 658 + 659 + // Should only have one call for the label action 660 + expect(accountActionsCounter.inc).toHaveBeenCalledTimes(1); 661 + expect(accountActionsCounter.inc).toHaveBeenCalledWith({ 662 + action_type: "label", 663 + label_type: "spam", 664 + success: "true", 665 + }); 451 666 }); 452 667 }); 453 668 });
+11
src/trackPostLabel.ts
··· 1 1 import { logger } from "./logger.js"; 2 2 import { TRACKED_LABELS } from "./config.js"; 3 3 import { addPostAndCheckThreshold } from "./redis.js"; 4 + import { postsTrackedCounter, thresholdsMetCounter } from "./metrics.js"; 4 5 import type { TrackedLabelConfig } from "./types.js"; 5 6 6 7 /** ··· 44 45 // Record the post and get current count 45 46 const currentCount = await addPostAndCheckThreshold(did, atURI, config); 46 47 48 + // Increment tracking metric 49 + postsTrackedCounter.inc({ label_type: label }); 50 + 47 51 logger.info( 48 52 { 49 53 process: "TRACK_LABEL", ··· 57 61 58 62 // Check if threshold is met 59 63 if (currentCount >= config.threshold) { 64 + // Increment threshold met metric 65 + thresholdsMetCounter.inc({ 66 + label_type: label, 67 + account_label: config.accountLabel, 68 + }); 69 + 60 70 logger.warn( 61 71 { 62 72 process: "TRACK_LABEL", ··· 64 74 label, 65 75 currentCount, 66 76 threshold: config.threshold, 77 + accountLabel: config.accountLabel, 67 78 }, 68 79 `Threshold met for ${label} - triggering account action`, 69 80 );
+23
src/triggerAccountLabel.ts
··· 4 4 createAccountReport, 5 5 createAccountComment, 6 6 } from "./moderation.js"; 7 + import { accountActionsCounter } from "./metrics.js"; 7 8 import type { AccountLabelAction } from "./trackPostLabel.js"; 8 9 9 10 /** ··· 57 58 // Step 1: Always label the account 58 59 await createAccountLabel(did, config.accountLabel, config.accountComment); 59 60 result.labeled = true; 61 + accountActionsCounter.inc({ 62 + action_type: "label", 63 + label_type: config.label, 64 + success: "true", 65 + }); 60 66 61 67 logger.info( 62 68 { ··· 71 77 if (config.reportAcct) { 72 78 await createAccountReport(did, config.accountComment); 73 79 result.reported = true; 80 + accountActionsCounter.inc({ 81 + action_type: "report", 82 + label_type: config.label, 83 + success: "true", 84 + }); 74 85 75 86 logger.info( 76 87 { ··· 85 96 if (config.commentAcct) { 86 97 await createAccountComment(did, config.accountComment); 87 98 result.commented = true; 99 + accountActionsCounter.inc({ 100 + action_type: "comment", 101 + label_type: config.label, 102 + success: "true", 103 + }); 88 104 89 105 logger.info( 90 106 { ··· 120 136 121 137 result.error = 122 138 error instanceof Error ? error.message : String(error); 139 + 140 + // Track failure metric 141 + accountActionsCounter.inc({ 142 + action_type: result.labeled ? "report_or_comment" : "label", 143 + label_type: config.label, 144 + success: "false", 145 + }); 123 146 124 147 logger.error( 125 148 {