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 months 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
48let onlyRunsNewerThanDate;
49
50if (onlyRunsNewerThan) {
51    onlyRunsNewerThanDate = new Date(onlyRunsNewerThan);
52    if (Number.isNaN(onlyRunsNewerThanDate.getTime())) {
53        throw Actor.fail('Invalid date format for onlyRunsNewerThan, use YYYY-MM-DD or with time YYYY-MM-DDTHH:mm:ss');
54    }
55}
56
57let onlyRunsOlderThanDate;
58
59if (onlyRunsOlderThan) {
60    onlyRunsOlderThanDate = new Date(onlyRunsOlderThan);
61    if (Number.isNaN(onlyRunsOlderThanDate.getTime())) {
62        throw Actor.fail('Invalid date format for onlyRunsOlderThan, use YYYY-MM-DD or with time YYYY-MM-DDTHH:mm:ss');
63    }
64}
65
66const runsClient = Actor.apifyClient.actor(actorIdOrName).runs();
67
68const state = await useState<State>(
69    'STATE',
70    { lastProcessedOffset: 0, lastProcessedRunId: null, dateAggregations: {} },
71);
72
73const LIMIT = 1000;
74let offset = state.lastProcessedOffset;
75for (; ;) {
76    const runs = await runsClient.list({ desc: true, limit: 1000, offset }).then((res) => res.items);
77
78    log.info(`Loaded ${runs.length} runs (offset from now: ${offset}), newest: ${runs[0]?.startedAt}, `
79        + `oldest: ${runs[runs.length - 1]?.startedAt} processing them now`);
80
81    const { stopLoop } = await processRuns({
82        runs,
83        state,
84        onlyRunsOlderThanDate,
85        onlyRunsNewerThanDate,
86        getCostBreakdown,
87        getDatasetItemCount,
88    });
89
90    state.lastProcessedOffset = offset;
91
92    if (stopLoop) {
93        log.warning(`Reached onlyRunsNewerThanDate ${onlyRunsNewerThanDate}, stopping loading runs`);
94        break;
95    }
96
97    if (runs.length < LIMIT) {
98        log.warning('No more runs to process, stopping loading runs');
99        break;
100    }
101
102    offset += LIMIT;
103}
104
105const totalStats: Omit<DateAggregation, 'date'> = {
106    runCount: 0,
107    cost: 0,
108    costDetail: {},
109    usageDetail: {},
110    firstRunDate: '',
111    lastRunDate: '',
112    buildNumbers: {},
113    statuses: {},
114    origins: {},
115};
116
117await Actor.pushData(Object.values(state.dateAggregations)
118    .map((aggregation: DateAggregation) => {
119        totalStats.runCount += aggregation.runCount;
120        totalStats.cost += aggregation.cost;
121        if (aggregation.datasetItems) {
122            if (!totalStats.datasetItems) {
123                totalStats.datasetItems = 0;
124            }
125            totalStats.datasetItems += aggregation.datasetItems;
126        }
127        if (!totalStats.lastRunDate) {
128            totalStats.lastRunDate = aggregation.lastRunDate;
129        }
130        totalStats.firstRunDate = aggregation.firstRunDate;
131        for (const [buildNumber, count] of Object.entries(aggregation.buildNumbers)) {
132            totalStats.buildNumbers[buildNumber] = (totalStats.buildNumbers[buildNumber] ?? 0) + count;
133        }
134        for (const [status, count] of Object.entries(aggregation.statuses)) {
135            totalStats.statuses[status] = (totalStats.statuses[status] ?? 0) + count;
136        }
137        for (const [origin, count] of Object.entries(aggregation.origins)) {
138            totalStats.origins[origin] = (totalStats.origins[origin] ?? 0) + count;
139        }
140
141        const cleanedCostDetail: Record<string, number> = {};
142
143        for (const [usageType, usageUsd] of Object.entries(aggregation.costDetail)) {
144            cleanedCostDetail[usageType] = Number(usageUsd.toFixed(4));
145            totalStats.costDetail[usageType] ??= 0
146            totalStats.costDetail[usageType] += Number(usageUsd.toFixed(4))
147        }
148
149        const cleanedUsageDetail: Record<string, number> = {};
150
151        for (const [usageType, usage] of Object.entries(aggregation.usageDetail)) {
152            cleanedUsageDetail[usageType] = Number(usage.toFixed(4));
153            totalStats.usageDetail[usageType] ??= 0
154            totalStats.usageDetail[usageType] += Number(usage.toFixed(4))
155        }
156
157        return { ...aggregation, cost: Number(aggregation.cost.toFixed(4)), costDetail: cleanedCostDetail, usageDetail: cleanedUsageDetail };
158    }));
159
160await Actor.setValue('STATE', state);
161await Actor.setValue('TOTAL_STATS', totalStats);
162
163const store = await Actor.openKeyValueStore();
164const url = store.getPublicUrl('TOTAL_STATS');
165await 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.1.10",
11		"crawlee": "^3.5.4"
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.