website-carbon-meter
Version:
Tracks the carbon emissions of a website as live metrics. Based on CO2.js and the actual grid intensity.
186 lines (167 loc) • 7.17 kB
JavaScript
import tgwf from '@tgwf/co2';
export default class CarbonMeter {
#listner = (totalEmission, estimatedCO2) => { };
#location = 'de';
#co2 = undefined;
#totalEmissionsCacheEntry = new CacheEntry(window.sessionStorage, "carbonMeter.totalEmission", Number.MAX_SAFE_INTEGER);
#carbonIntensityCacheEntry;
#forecastDataCacheEntry;
constructor(location) {
if (location) {
this.#location = location;
console.info(`CarbonMeter: Choose '${location}' for gathering grid carbon intensity`);
}
this.#co2 = new tgwf.co2({ model: "swd", version: 4 });
let tenMinutes = 600000;
let fourHours = 14400000;
this.#carbonIntensityCacheEntry = new CacheEntry(window.sessionStorage, `carbonMeter.${location}.carbonIntensity`, tenMinutes);
this.#forecastDataCacheEntry = new CacheEntry(window.localStorage, `carbonMeter.${location}.forecastData`, fourHours);
}
start() {
setTimeout(() => {
this.#startMetering();
}, 1);
}
/**
* Registers a listener function to handle metering data.
*
* @param {function(number, number): void} listnerFunc - The listener function that will be called with the total CO2 emissions and the estimated CO2 emissions for the current metering event.
*/
onMetering(listnerFunc) {
this.#listner = listnerFunc;
}
async #startMetering() {
await this.#getEmissionsFromBrowserRessources();
await this.#observerEmissionsFromBackgroundTransfer();
}
async #observerEmissionsFromBackgroundTransfer() {
let carbonIntensity = await this.#getCarbonIntensity();
const observer = new PerformanceObserver((list) => {
setTimeout(() => {
for (const entry of list.getEntries()) {
if (entry.initiatorType === "fetch" || entry.initiatorType === "xmlhttprequest" || entry.initiatorType === "img" || entry.initiatorType === "script" ) {
let bytesSent = entry.transferSize;
this.#calculateAndReportEmissions("From Background",bytesSent, carbonIntensity);
console.debug(`${entry.initiatorType}: Count ${bytesSent} bytes in background from ${entry.name}`);
}
}
}, 1);
});
observer.observe({
entryTypes: ["resource"]
});
}
async #getEmissionsFromBrowserRessources() {
let carbonIntensity = await this.#getCarbonIntensity();
let bytesSent = this.#getTransferSize();
this.#calculateAndReportEmissions("From Browser", bytesSent, carbonIntensity);
}
#calculateAndReportEmissions(context, bytesTransfered, carbonIntensity) {
let result = this.#co2.perByteTrace(
bytesTransfered,
false,
{
gridIntensity: {
device: carbonIntensity,
dataCenter: carbonIntensity,
networks: carbonIntensity,
}
}
)
let estimatedCO2 = parseFloat(result.co2.toFixed(2));
console.debug(`Report: ${context}, Bytes transfered: ${bytesTransfered}, Grid intensity: ${carbonIntensity}, Carbon: ${estimatedCO2}`);
this.#reportEmissions(estimatedCO2);
}
#reportEmissions(estimatedCO2) {
let totalEmission = parseFloat(this.#totalEmissionsCacheEntry.getItem());
if (totalEmission) {
totalEmission += estimatedCO2;
}
else {
totalEmission = estimatedCO2;
}
this.#totalEmissionsCacheEntry.setItem(totalEmission);
if (this.#listner) {
this.#listner(totalEmission, estimatedCO2);
}
}
#getTransferSize = () => {
let totalTransferSize = 0;
performance.getEntriesByType('resource').map((resource) => {
const data = resource.toJSON();
totalTransferSize += data.transferSize;
console.debug(`Count ${data.transferSize} bytes in browser from ${data.name}`);
});
performance.getEntriesByType('navigation').map((resource) => {
const data = resource.toJSON();
totalTransferSize += data.transferSize;
console.debug(`Count ${data.transferSize} bytes in browser from ${data.name}`);
});
return totalTransferSize;
};
#calculateCarbonIntensityFromForecast(forecastJson) {
let forecast = JSON.parse(forecastJson);
let start = forecast.Start;
let intervall = forecast.Interval;
let ratings = forecast.Ratings;
let now = Date.now();
let currentIndex = Math.round((now - start) / intervall);
if (currentIndex >= 0 && currentIndex < ratings.length) {
let rating = ratings[currentIndex];
console.debug(`Current Grid CO2 Intensity: ${rating}`);
return rating;
}
return null;
}
async #getCarbonIntensity() {
let carbonIntensity = parseFloat(this.#carbonIntensityCacheEntry.getItem());
if (carbonIntensity === undefined || Number.isNaN(carbonIntensity)) {
let forecastDataJson = this.#forecastDataCacheEntry.getItem();
if (forecastDataJson === undefined) {
let forecastData = await this.#getForcastFromServer();
forecastDataJson = JSON.stringify(forecastData)
this.#forecastDataCacheEntry.setItem(forecastDataJson);
}
carbonIntensity = this.#calculateCarbonIntensityFromForecast(forecastDataJson);
if (carbonIntensity) {
this.#carbonIntensityCacheEntry.setItem(carbonIntensity);
}
}
return carbonIntensity;
}
async #getForcastFromServer() {
let forecast = await this.#fetchData(this.#location);
return forecast;
}
async #fetchData(location) {
let response = await fetch(`https://carbonawarecomputing.blob.core.windows.net/forecasts/${location}.min.json`);
let data = await response.json();
return data;
}
}
class CacheEntry {
#keyName;
#dueKeyName;
#due;
#cacheProvider;
constructor(cacheProvider, keyName, dueInMiliSeconds) {
this.#due = dueInMiliSeconds;
this.#keyName = keyName;
this.#dueKeyName = keyName + ".Updated"
this.#cacheProvider = cacheProvider;
}
getItem() {
let item = this.#cacheProvider.getItem(this.#keyName);
if (item) {
let lastUpdate = parseInt(this.#cacheProvider.getItem(this.#dueKeyName));
if (lastUpdate + this.#due > Date.now()) {
return item;
}
}
return undefined;
}
setItem(value) {
this.#cacheProvider.setItem(this.#keyName, value);
this.#cacheProvider.setItem(this.#dueKeyName, Date.now());
}
}