
Actor Costs
Pricing
Pay per usage
Go to Store

Actor Costs
Get costs and usage stats for your actor use aggregated daily. The actor also provides summary stats for the whole period.
0.0 (0)
Pricing
Pay per usage
3
Monthly users
4
Runs succeeded
>99%
Last modified
2 days ago
.actor/Dockerfile
1# Specify the base Docker image. You can read more about
2# the available images at https://crawlee.dev/docs/guides/docker-images
3# You can also use any other image from Docker Hub.
4FROM apify/actor-node:20 AS builder
5
6# Copy just package.json and package-lock.json
7# to speed up the build using Docker layer cache.
8COPY package*.json ./
9
10# Install all dependencies. Don't audit to speed up the installation.
11RUN npm install --include=dev --audit=false
12
13# Next, copy the source files using the user set
14# in the base image.
15COPY . ./
16
17# Install all dependencies and build the project.
18# Don't audit to speed up the installation.
19RUN npm run build
20
21# Create final image
22FROM apify/actor-node:20
23
24# Copy just package.json and package-lock.json
25# to speed up the build using Docker layer cache.
26COPY package*.json ./
27
28# Install NPM packages, skip optional and development dependencies to
29# keep the image small. Avoid logging too much and print the dependency
30# tree for debugging
31RUN npm --quiet set progress=false \
32 && npm install --omit=dev --omit=optional \
33 && echo "Installed NPM packages:" \
34 && (npm list --omit=dev --all || true) \
35 && echo "Node.js version:" \
36 && node --version \
37 && echo "NPM version:" \
38 && npm --version \
39 && rm -r ~/.npm
40
41# Copy built JS files from builder image
42COPY /usr/src/app/dist ./dist
43
44# Next, copy the remaining files and directories with the source code.
45# Since we do this after NPM install, quick build will be really fast
46# for most source file changes.
47COPY . ./
48
49
50# Run the image.
51CMD npm run start:prod --silent
.actor/actor.json
1{
2 "actorSpecification": 1,
3 "name": "actor-costs",
4 "title": "Project Cheerio Crawler Typescript",
5 "description": "Crawlee and Cheerio project in typescript.",
6 "version": "0.0",
7 "meta": {
8 "templateId": "ts-crawlee-cheerio"
9 },
10 "input": "./input_schema.json",
11 "dockerfile": "./Dockerfile"
12}
.actor/input_schema.json
1{
2 "title": "CheerioCrawler Template",
3 "type": "object",
4 "schemaVersion": 1,
5 "properties": {
6 "actorIdOrName": {
7 "title": "Actor ID or full name",
8 "type": "string",
9 "description": "Actor ID or full name",
10 "editor": "textfield",
11 "prefill": "apify/web-scraper"
12 },
13 "onlyRunsNewerThan": {
14 "title": "Only runs newer than date",
15 "type": "string",
16 "description": "Measured by when the run was started. Use JSON input to specify date with a time in ISO format, e.g. \"2024-01-01T12:00:00\"",
17 "editor": "datepicker"
18 },
19 "onlyRunsOlderThan": {
20 "title": "Only runs older than date",
21 "type": "string",
22 "description": "Measured by when the run was started. Use JSON input to specify date with a time in ISO format, e.g. \"2024-01-01T12:00:00\"",
23 "editor": "datepicker"
24 },
25 "getCostBreakdown": {
26 "title": "Get cost breakdown by usage type (1000x slower!)",
27 "type": "boolean",
28 "description": "Very slow since we need to request each run separately",
29 "default": false
30 },
31 "getDatasetItemCount": {
32 "title": "Get dataset item count (1000x slower!)",
33 "type": "boolean",
34 "description": "Very slow since we need to request each run separately",
35 "default": false
36 }
37 },
38 "required": ["actorIdOrName"]
39}
src/main.ts
1import { Actor, log } from 'apify';
2import { useState } from 'crawlee';
3import { processRuns } from './process-runs.js';
4
5interface Input {
6 actorIdOrName: string;
7 onlyRunsNewerThan?: string;
8 onlyRunsOlderThan?: string;
9 getCostBreakdown?: boolean;
10 getDatasetItemCount?: boolean;
11}
12
13interface DateAggregation {
14 date: string,
15 runCount: number,
16 cost: number,
17 // Only when requested in input
18 datasetItems?: number,
19 costDetail: Record<string, number>,
20 usageDetail: Record<string, number>,
21 firstRunDate: string,
22 lastRunDate: string,
23 buildNumbers: Record<string, number>,
24 statuses: Record<string, number>,
25 origins: Record<string, number>,
26}
27
28type DateAggregations = Record<string, DateAggregation>;
29
30// { date: stats }
31export interface State {
32 dateAggregations: DateAggregations;
33 lastProcessedRunId: string | null;
34 lastProcessedOffset: number;
35}
36
37// The init() call configures the Actor for its environment. It's recommended to start every Actor with an init()
38await Actor.init();
39
40const {
41 actorIdOrName,
42 onlyRunsNewerThan,
43 onlyRunsOlderThan,
44 getCostBreakdown = false,
45 getDatasetItemCount = false
46} = (await Actor.getInput<Input>())!;
47
48console.log(`Loaded input`)
49
50let onlyRunsNewerThanDate;
51
52if (onlyRunsNewerThan) {
53 onlyRunsNewerThanDate = new Date(onlyRunsNewerThan);
54 if (Number.isNaN(onlyRunsNewerThanDate.getTime())) {
55 await Actor.fail('Invalid date format for onlyRunsNewerThan, use YYYY-MM-DD or with time YYYY-MM-DDTHH:mm:ss');
56 }
57}
58
59let onlyRunsOlderThanDate;
60
61if (onlyRunsOlderThan) {
62 onlyRunsOlderThanDate = new Date(onlyRunsOlderThan);
63 if (Number.isNaN(onlyRunsOlderThanDate.getTime())) {
64 await Actor.fail('Invalid date format for onlyRunsOlderThan, use YYYY-MM-DD or with time YYYY-MM-DDTHH:mm:ss');
65 }
66}
67
68if (onlyRunsNewerThanDate && onlyRunsOlderThanDate && onlyRunsNewerThanDate > onlyRunsOlderThanDate) {
69 await Actor.fail(`'onlyRunsNewerThan' must be an older date than 'onlyRunsOlderThan'`)
70}
71
72console.log(`Input is valid`)
73
74const runsClient = Actor.apifyClient.actor(actorIdOrName).runs();
75
76const state = await useState<State>(
77 'STATE',
78 { lastProcessedOffset: 0, lastProcessedRunId: null, dateAggregations: {} },
79);
80
81const LIMIT = 1000;
82let offset = state.lastProcessedOffset;
83for (; ;) {
84 const runs = await runsClient.list({ desc: true, limit: 1000, offset }).then((res) => res.items);
85
86 log.info(`Loaded ${runs.length} runs (offset from now: ${offset}), newest: ${runs[0]?.startedAt}, `
87 + `oldest: ${runs[runs.length - 1]?.startedAt} processing them now`);
88
89 const { stopLoop } = await processRuns({
90 runs,
91 state,
92 onlyRunsOlderThanDate,
93 onlyRunsNewerThanDate,
94 getCostBreakdown,
95 getDatasetItemCount,
96 });
97
98 state.lastProcessedOffset = offset;
99
100 if (stopLoop) {
101 log.warning(`Reached onlyRunsNewerThanDate ${onlyRunsNewerThanDate}, stopping loading runs`);
102 break;
103 }
104
105 if (runs.length < LIMIT) {
106 log.warning('No more runs to process, stopping loading runs');
107 break;
108 }
109
110 offset += LIMIT;
111}
112
113const totalStats: Omit<DateAggregation, 'date'> = {
114 runCount: 0,
115 cost: 0,
116 costDetail: {},
117 usageDetail: {},
118 firstRunDate: '',
119 lastRunDate: '',
120 buildNumbers: {},
121 statuses: {},
122 origins: {},
123};
124
125await Actor.pushData(Object.values(state.dateAggregations)
126 .map((aggregation: DateAggregation) => {
127 totalStats.runCount += aggregation.runCount;
128 totalStats.cost += aggregation.cost;
129 if (aggregation.datasetItems) {
130 if (!totalStats.datasetItems) {
131 totalStats.datasetItems = 0;
132 }
133 totalStats.datasetItems += aggregation.datasetItems;
134 }
135 if (!totalStats.lastRunDate) {
136 totalStats.lastRunDate = aggregation.lastRunDate;
137 }
138 totalStats.firstRunDate = aggregation.firstRunDate;
139 for (const [buildNumber, count] of Object.entries(aggregation.buildNumbers)) {
140 totalStats.buildNumbers[buildNumber] = (totalStats.buildNumbers[buildNumber] ?? 0) + count;
141 }
142 for (const [status, count] of Object.entries(aggregation.statuses)) {
143 totalStats.statuses[status] = (totalStats.statuses[status] ?? 0) + count;
144 }
145 for (const [origin, count] of Object.entries(aggregation.origins)) {
146 totalStats.origins[origin] = (totalStats.origins[origin] ?? 0) + count;
147 }
148
149 const cleanedCostDetail: Record<string, number> = {};
150
151 for (const [usageType, usageUsd] of Object.entries(aggregation.costDetail)) {
152 cleanedCostDetail[usageType] = Number(usageUsd.toFixed(4));
153 totalStats.costDetail[usageType] ??= 0
154 totalStats.costDetail[usageType] += Number(usageUsd.toFixed(4))
155 }
156
157 const cleanedUsageDetail: Record<string, number> = {};
158
159 for (const [usageType, usage] of Object.entries(aggregation.usageDetail)) {
160 cleanedUsageDetail[usageType] = Number(usage.toFixed(4));
161 totalStats.usageDetail[usageType] ??= 0
162 totalStats.usageDetail[usageType] += Number(usage.toFixed(4))
163 }
164
165 return { ...aggregation, cost: Number(aggregation.cost.toFixed(4)), costDetail: cleanedCostDetail, usageDetail: cleanedUsageDetail };
166 }));
167
168await Actor.setValue('STATE', state);
169await Actor.setValue('TOTAL_STATS', totalStats);
170
171const store = await Actor.openKeyValueStore();
172const url = store.getPublicUrl('TOTAL_STATS');
173await Actor.exit(`Total stats for whole period are available at ${url}`);
src/process-runs.ts
1import { Actor, log } from 'apify';
2
3import type { ActorRunListItem, ActorRun } from 'apify-client';
4import { sleep } from 'crawlee';
5import type { State } from './main.js';
6
7interface ProcessRunsInputs {
8 runs: ActorRunListItem[];
9 state: State;
10 onlyRunsOlderThanDate?: Date;
11 onlyRunsNewerThanDate?: Date;
12 getCostBreakdown: boolean;
13 getDatasetItemCount: boolean;
14}
15
16let isMigrating = false;
17Actor.on('migrating', () => {
18 isMigrating = true;
19});
20
21let foundLastProcessedRun = false;
22
23export const processRuns = async ({ runs, state, onlyRunsOlderThanDate, onlyRunsNewerThanDate, getCostBreakdown, getDatasetItemCount }: ProcessRunsInputs): Promise<{ stopLoop: boolean }> => {
24 // Runs are in decs mode
25 for (let run of runs) {
26 if (getCostBreakdown) {
27 run = (await Actor.apifyClient.run(run.id).get())! as ActorRun
28 }
29
30 let cleanItemCount = null;
31 if (getDatasetItemCount) {
32 cleanItemCount = await Actor.apifyClient.dataset(run.defaultDatasetId).get().then((res) => res!.cleanItemCount);
33 }
34
35 if (isMigrating) {
36 log.warning('Actor is migrating, pausing all processing and storing last state to continue where we left of');
37 state.lastProcessedRunId = run.id;
38 await sleep(999999);
39 }
40
41 // If we load after migration, we need to find run we already processed
42 if (state.lastProcessedRunId && !foundLastProcessedRun) {
43 const isLastProcessed = state.lastProcessedRunId === run.id;
44 if (isLastProcessed) {
45 foundLastProcessedRun = true;
46 state.lastProcessedRunId = null;
47 } else {
48 log.warning(`Skipping run we already processed before migration ${run.id}`);
49 continue;
50 }
51 }
52
53 if (onlyRunsOlderThanDate && run.startedAt > onlyRunsOlderThanDate) {
54 continue;
55 }
56 if (onlyRunsNewerThanDate && run.startedAt < onlyRunsNewerThanDate) {
57 // We are going from present to past so at this point we can exit
58 return { stopLoop: true };
59 }
60
61 const runDate = run.startedAt.toISOString().split('T')[0];
62 state.dateAggregations[runDate] ??= {
63 date: runDate,
64 runCount: 0,
65 cost: 0,
66 costDetail: {},
67 usageDetail: {},
68 firstRunDate: run.startedAt.toISOString(),
69 lastRunDate: run.startedAt.toISOString(),
70 buildNumbers: {},
71 statuses: {},
72 origins: {},
73 };
74
75 state.dateAggregations[runDate].runCount++;
76 state.dateAggregations[runDate].cost += run.usageTotalUsd ?? 0;
77
78
79 if ((run as ActorRun).usageUsd) {
80 for (const [usageType, usageUsd] of Object.entries((run as ActorRun).usageUsd as Record<string, number>)) {
81 state.dateAggregations[runDate].costDetail[usageType] ??= 0;
82 state.dateAggregations[runDate].costDetail[usageType] += usageUsd;
83 }
84 }
85
86 if ((run as ActorRun).usage) {
87 for (const [usageType, usage] of Object.entries((run as ActorRun).usage as Record<string, number>)) {
88 state.dateAggregations[runDate].usageDetail[usageType] ??= 0;
89 state.dateAggregations[runDate].usageDetail[usageType] += usage;
90 }
91 }
92
93 // lastRunDate is always the first we encounter because we go desc so we don't have to update it
94 state.dateAggregations[runDate].firstRunDate = run.startedAt.toISOString();
95
96 state.dateAggregations[runDate].buildNumbers[run.buildNumber] ??= 0;
97 state.dateAggregations[runDate].buildNumbers[run.buildNumber]++;
98
99 state.dateAggregations[runDate].statuses[run.status] ??= 0;
100 state.dateAggregations[runDate].statuses[run.status]++;
101
102 state.dateAggregations[runDate].origins[run.meta.origin] ??= 0;
103 state.dateAggregations[runDate].origins[run.meta.origin]++;
104
105 if (getDatasetItemCount && cleanItemCount !== null) {
106 state.dateAggregations[runDate].datasetItems ??= 0;
107 state.dateAggregations[runDate].datasetItems += cleanItemCount;
108 }
109 }
110
111 return { stopLoop: false };
112};
.dockerignore
1# configurations
2.idea
3
4# crawlee and apify storage folders
5apify_storage
6crawlee_storage
7storage
8
9# installed files
10node_modules
11
12# git folder
13.git
.editorconfig
1root = true
2
3[*]
4indent_style = space
5indent_size = 4
6charset = utf-8
7trim_trailing_whitespace = true
8insert_final_newline = true
9end_of_line = lf
.eslintrc
1{
2 "root": true,
3 "env": {
4 "browser": true,
5 "es2020": true,
6 "node": true
7 },
8 "extends": [
9 "@apify/eslint-config-ts"
10 ],
11 "parserOptions": {
12 "project": "./tsconfig.json",
13 "ecmaVersion": 2020
14 },
15 "ignorePatterns": [
16 "node_modules",
17 "dist",
18 "**/*.d.ts"
19 ]
20}
.gitignore
1# This file tells Git which files shouldn't be added to source control
2
3.DS_Store
4.idea
5dist
6node_modules
7apify_storage
8storage
9
10# Added by Apify CLI
11.venv
package.json
1{
2 "name": "actor-costs",
3 "version": "0.0.1",
4 "type": "module",
5 "description": "This is a boilerplate of an Apify actor.",
6 "engines": {
7 "node": ">=18.0.0"
8 },
9 "dependencies": {
10 "apify": "^3.4",
11 "crawlee": "^3.13"
12 },
13 "devDependencies": {
14 "@apify/eslint-config-ts": "^0.3.0",
15 "@apify/tsconfig": "^0.1.0",
16 "@typescript-eslint/eslint-plugin": "^6.7.2",
17 "@typescript-eslint/parser": "^6.7.2",
18 "eslint": "^8.50.0",
19 "tsx": "^4.6.2",
20 "typescript": "^5.5"
21 },
22 "scripts": {
23 "start": "npm run start:dev",
24 "start:prod": "node dist/main.js",
25 "start:dev": "tsx src/main.ts",
26 "build": "tsc",
27 "lint": "eslint ./src --ext .ts",
28 "lint:fix": "eslint ./src --ext .ts --fix",
29 "test": "echo \"Error: oops, the actor has no tests yet, sad!\" && exit 1"
30 },
31 "author": "It's not you it's me",
32 "license": "ISC"
33}
tsconfig.json
1{
2 "extends": "@apify/tsconfig",
3 "compilerOptions": {
4 "module": "NodeNext",
5 "moduleResolution": "NodeNext",
6 "target": "ES2022",
7 "outDir": "dist",
8 "noUnusedLocals": false,
9 "skipLibCheck": true,
10 "lib": ["DOM"]
11 },
12 "include": [
13 "./src/**/*"
14 ]
15}
Pricing
Pricing model
Pay per usageThis Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage.