Files
oneuptime/Common/Server/Services/TelemetryUsageBillingService.ts
Nawaz Dhandala e0f1da768b Refactor and clean up code across multiple components and services
- Simplified useEffect dependencies in ProfileTimeline component.
- Streamlined option rendering in ProfileTypeSelector component.
- Reformatted TelemetryDocumentation component for better readability.
- Improved formatting and consistency in AuthenticationSettings and Branding pages.
- Removed unused imports in ProfileViewPage.
- Enhanced code readability in StatusPageDelete component.
- Consolidated RouteMap definitions for better clarity.
- Cleaned up API response handling in PublicDashboard and DashboardViewPage.
- Refactored ProfileSample and DashboardAPI for improved code structure.
- Updated migration scripts for better readability and consistency.
- Enhanced incident and profile aggregation services for better clarity.
- Cleaned up telemetry ingestion service for improved readability.
- Improved test cases for OtelProfilesIngestService for better clarity.
2026-03-27 13:01:32 +00:00

482 lines
14 KiB
TypeScript

import { MeteredPlanUtil } from "../Types/Billing/MeteredPlan/AllMeteredPlans";
import TelemetryMeteredPlan from "../Types/Billing/MeteredPlan/TelemetryMeteredPlan";
import DatabaseService from "./DatabaseService";
import SortOrder from "../../Types/BaseDatabase/SortOrder";
import LIMIT_MAX, { LIMIT_INFINITY } from "../../Types/Database/LimitMax";
import OneUptimeDate from "../../Types/Date";
import Decimal from "../../Types/Decimal";
import BadDataException from "../../Types/Exception/BadDataException";
import ProductType from "../../Types/MeteredPlan/ProductType";
import ObjectID from "../../Types/ObjectID";
import Model, {
DEFAULT_RETENTION_IN_DAYS,
} from "../../Models/DatabaseModels/TelemetryUsageBilling";
import ServiceService from "./ServiceService";
import SpanService from "./SpanService";
import LogService from "./LogService";
import MetricService from "./MetricService";
import ExceptionInstanceService from "./ExceptionInstanceService";
import ProfileService from "./ProfileService";
import ProfileSampleService from "./ProfileSampleService";
import AnalyticsQueryHelper from "../Types/AnalyticsDatabase/QueryHelper";
import DiskSize from "../../Types/DiskSize";
import logger from "../Utils/Logger";
import PositiveNumber from "../../Types/PositiveNumber";
import ServiceModel from "../../Models/DatabaseModels/Service";
import {
AverageSpanRowSizeInBytes,
AverageLogRowSizeInBytes,
AverageMetricRowSizeInBytes,
AverageExceptionRowSizeInBytes,
AverageProfileRowSizeInBytes,
AverageProfileSampleRowSizeInBytes,
IsBillingEnabled,
} from "../EnvironmentConfig";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
if (IsBillingEnabled) {
this.hardDeleteItemsOlderThanInDays("createdAt", 120);
}
}
@CaptureSpan()
public async getUnreportedUsageBilling(data: {
projectId: ObjectID;
productType: ProductType;
}): Promise<Model[]> {
return await this.findBy({
query: {
projectId: data.projectId,
productType: data.productType,
isReportedToBillingProvider: false,
},
skip: 0,
limit: LIMIT_MAX, /// because a project can have MANY telemetry services.
select: {
_id: true,
totalCostInUSD: true,
},
props: {
isRoot: true,
},
});
}
@CaptureSpan()
public async stageTelemetryUsageForProject(data: {
projectId: ObjectID;
productType: ProductType;
usageDate?: Date;
}): Promise<void> {
if (!IsBillingEnabled) {
return;
}
const usageDate: Date = data.usageDate
? OneUptimeDate.fromString(data.usageDate)
: OneUptimeDate.addRemoveDays(OneUptimeDate.getCurrentDate(), -1);
const averageRowSizeInBytes: number = this.getAverageRowSizeForProduct(
data.productType,
);
const averageExceptionRowSizeInBytes: number =
this.getAverageExceptionRowSize();
const averageProfileSampleRowSizeInBytes: number =
this.getAverageProfileSampleRowSize();
if (
data.productType !== ProductType.Traces &&
data.productType !== ProductType.Profiles &&
averageRowSizeInBytes <= 0
) {
return;
}
if (
data.productType === ProductType.Traces &&
averageRowSizeInBytes <= 0 &&
averageExceptionRowSizeInBytes <= 0
) {
return;
}
if (
data.productType === ProductType.Profiles &&
averageRowSizeInBytes <= 0 &&
averageProfileSampleRowSizeInBytes <= 0
) {
return;
}
const usageDayString: string = OneUptimeDate.getDateString(usageDate);
const startOfDay: Date = OneUptimeDate.getStartOfDay(usageDate);
const endOfDay: Date = OneUptimeDate.getEndOfDay(usageDate);
const services: Array<ServiceModel> = await ServiceService.findBy({
query: {
projectId: data.projectId,
},
select: {
_id: true,
retainTelemetryDataForDays: true,
},
skip: 0,
limit: LIMIT_MAX,
props: {
isRoot: true,
},
});
if (!services || services.length === 0) {
return;
}
for (const service of services) {
if (!service?.id) {
continue;
}
const existingEntry: Model | null = await this.findOneBy({
query: {
projectId: data.projectId,
productType: data.productType,
serviceId: service.id,
day: usageDayString,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (existingEntry) {
continue;
}
let estimatedBytes: number = 0;
try {
if (data.productType === ProductType.Traces) {
const spanCount: PositiveNumber = await SpanService.countBy({
query: {
projectId: data.projectId,
serviceId: service.id,
startTime: AnalyticsQueryHelper.inBetween(startOfDay, endOfDay),
},
skip: 0,
limit: LIMIT_INFINITY,
props: {
isRoot: true,
},
});
const exceptionCount: PositiveNumber =
await ExceptionInstanceService.countBy({
query: {
projectId: data.projectId,
serviceId: service.id,
time: AnalyticsQueryHelper.inBetween(startOfDay, endOfDay),
},
skip: 0,
limit: LIMIT_INFINITY,
props: {
isRoot: true,
},
});
const totalSpanCount: number = spanCount.toNumber();
const totalExceptionCount: number = exceptionCount.toNumber();
if (totalSpanCount <= 0 && totalExceptionCount <= 0) {
continue;
}
estimatedBytes =
totalSpanCount * averageRowSizeInBytes +
totalExceptionCount * averageExceptionRowSizeInBytes;
} else if (data.productType === ProductType.Logs) {
const count: PositiveNumber = await LogService.countBy({
query: {
projectId: data.projectId,
serviceId: service.id,
time: AnalyticsQueryHelper.inBetween(startOfDay, endOfDay),
},
skip: 0,
limit: LIMIT_INFINITY,
props: {
isRoot: true,
},
});
const totalRowCount: number = count.toNumber();
if (totalRowCount <= 0) {
continue;
}
estimatedBytes = totalRowCount * averageRowSizeInBytes;
} else if (data.productType === ProductType.Metrics) {
const count: PositiveNumber = await MetricService.countBy({
query: {
projectId: data.projectId,
serviceId: service.id,
time: AnalyticsQueryHelper.inBetween(startOfDay, endOfDay),
},
skip: 0,
limit: LIMIT_INFINITY,
props: {
isRoot: true,
},
});
const totalRowCount: number = count.toNumber();
if (totalRowCount <= 0) {
continue;
}
estimatedBytes = totalRowCount * averageRowSizeInBytes;
} else if (data.productType === ProductType.Profiles) {
const profileCount: PositiveNumber = await ProfileService.countBy({
query: {
projectId: data.projectId,
serviceId: service.id,
startTime: AnalyticsQueryHelper.inBetween(startOfDay, endOfDay),
},
skip: 0,
limit: LIMIT_INFINITY,
props: {
isRoot: true,
},
});
const profileSampleCount: PositiveNumber =
await ProfileSampleService.countBy({
query: {
projectId: data.projectId,
serviceId: service.id,
time: AnalyticsQueryHelper.inBetween(startOfDay, endOfDay),
},
skip: 0,
limit: LIMIT_INFINITY,
props: {
isRoot: true,
},
});
const totalProfileCount: number = profileCount.toNumber();
const totalProfileSampleCount: number = profileSampleCount.toNumber();
if (totalProfileCount <= 0 && totalProfileSampleCount <= 0) {
continue;
}
estimatedBytes =
totalProfileCount * averageRowSizeInBytes +
totalProfileSampleCount * averageProfileSampleRowSizeInBytes;
}
} catch (error) {
logger.error(
`Failed to compute telemetry usage for service ${service.id?.toString()}:`,
);
logger.error(error as Error);
continue;
}
if (estimatedBytes <= 0) {
continue;
}
const estimatedGigabytes: number = DiskSize.byteSizeToGB(estimatedBytes);
if (!Number.isFinite(estimatedGigabytes) || estimatedGigabytes <= 0) {
continue;
}
const dataRetentionInDays: number =
service.retainTelemetryDataForDays || DEFAULT_RETENTION_IN_DAYS;
await this.updateUsageBilling({
projectId: data.projectId,
productType: data.productType,
serviceId: service.id,
dataIngestedInGB: estimatedGigabytes,
retentionInDays: dataRetentionInDays,
usageDate: usageDate,
});
}
}
@CaptureSpan()
public async updateUsageBilling(data: {
projectId: ObjectID;
productType: ProductType;
serviceId: ObjectID;
dataIngestedInGB: number;
retentionInDays: number;
usageDate?: Date;
}): Promise<void> {
if (
data.productType !== ProductType.Traces &&
data.productType !== ProductType.Metrics &&
data.productType !== ProductType.Logs &&
data.productType !== ProductType.Profiles
) {
throw new BadDataException(
"This product type is not a telemetry product type.",
);
}
const serverMeteredPlan: TelemetryMeteredPlan =
MeteredPlanUtil.getMeteredPlanByProductType(
data.productType,
) as TelemetryMeteredPlan;
const usageDate: Date = data.usageDate
? OneUptimeDate.fromString(data.usageDate)
: OneUptimeDate.getCurrentDate();
const usageDayString: string = OneUptimeDate.getDateString(usageDate);
const totalCostOfThisOperationInUSD: number =
serverMeteredPlan.getTotalCostInUSD({
dataIngestedInGB: data.dataIngestedInGB,
retentionInDays: data.retentionInDays,
});
const usageBilling: Model | null = await this.findOneBy({
query: {
projectId: data.projectId,
productType: data.productType,
serviceId: data.serviceId,
isReportedToBillingProvider: false,
day: usageDayString,
},
select: {
_id: true,
dataIngestedInGB: true,
totalCostInUSD: true,
},
props: {
isRoot: true,
},
sort: {
createdAt: SortOrder.Descending,
},
});
if (usageBilling && usageBilling.id) {
let totalCostInUSD: number = usageBilling.totalCostInUSD?.value || 0;
if (
isNaN(totalCostInUSD) ||
totalCostInUSD === undefined ||
totalCostInUSD === null ||
(typeof totalCostInUSD === "string" && totalCostInUSD === "NaN")
) {
totalCostInUSD = 0;
}
await this.updateOneById({
id: usageBilling.id,
data: {
dataIngestedInGB: new Decimal(
(usageBilling.dataIngestedInGB?.value || 0) + data.dataIngestedInGB,
),
totalCostInUSD: new Decimal(
totalCostInUSD + totalCostOfThisOperationInUSD,
),
retainTelemetryDataForDays: data.retentionInDays,
},
props: {
isRoot: true,
},
});
} else {
const usageBilling: Model = new Model();
usageBilling.projectId = data.projectId;
usageBilling.productType = data.productType;
usageBilling.dataIngestedInGB = new Decimal(data.dataIngestedInGB);
usageBilling.serviceId = data.serviceId;
usageBilling.retainTelemetryDataForDays = data.retentionInDays;
usageBilling.isReportedToBillingProvider = false;
usageBilling.createdAt = usageDate;
usageBilling.day = usageDayString;
usageBilling.totalCostInUSD = new Decimal(totalCostOfThisOperationInUSD);
await this.create({
data: usageBilling,
props: {
isRoot: true,
},
});
}
}
private getAverageRowSizeForProduct(productType: ProductType): number {
const fallbackSize: number = 1024;
// Narrow to telemetry product types before indexing to satisfy TypeScript
if (
productType !== ProductType.Traces &&
productType !== ProductType.Logs &&
productType !== ProductType.Metrics &&
productType !== ProductType.Profiles
) {
return fallbackSize;
}
const value: number =
{
[ProductType.Traces]: AverageSpanRowSizeInBytes,
[ProductType.Logs]: AverageLogRowSizeInBytes,
[ProductType.Metrics]: AverageMetricRowSizeInBytes,
[ProductType.Profiles]: AverageProfileRowSizeInBytes,
}[productType] ?? fallbackSize;
if (!Number.isFinite(value) || value <= 0) {
return fallbackSize;
}
return value;
}
private getAverageExceptionRowSize(): number {
const fallbackSize: number = 1024;
if (!Number.isFinite(AverageExceptionRowSizeInBytes)) {
return fallbackSize;
}
if (AverageExceptionRowSizeInBytes <= 0) {
return fallbackSize;
}
return AverageExceptionRowSizeInBytes;
}
private getAverageProfileSampleRowSize(): number {
const fallbackSize: number = 512;
if (!Number.isFinite(AverageProfileSampleRowSizeInBytes)) {
return fallbackSize;
}
if (AverageProfileSampleRowSizeInBytes <= 0) {
return fallbackSize;
}
return AverageProfileSampleRowSizeInBytes;
}
}
export default new Service();