Actor Costs avatar
Actor Costs

Pricing

Pay per usage

Go to Store
Actor Costs

Actor Costs

Developed by

Lukáš Křivka

Maintained by Community

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 --from=builder /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 usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage.