Skip to content

Commit 404d6bb

Browse files
authored
Merge pull request #751 from cloudflare/jspspike/analytics-engine
Add AnalyticsEngine binding type to workerd
2 parents 7a1cfdb + 178a432 commit 404d6bb

File tree

6 files changed

+159
-9
lines changed

6 files changed

+159
-9
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as assert from 'node:assert';
2+
3+
let written = false;
4+
async function isWritten(timeout) {
5+
const start = Date.now();
6+
do {
7+
if (written) return true;
8+
await scheduler.wait(100);
9+
} while ((Date.now() - start) < timeout);
10+
throw new Error("Test never received request from analytics engine handler");
11+
}
12+
13+
export default {
14+
async fetch(ctrl, env, ctx) {
15+
written = true
16+
return new Response("");
17+
},
18+
async test(ctrl, env, ctx) {
19+
env.aebinding.writeDataPoint({
20+
'blobs': ["TestBlob"],
21+
'doubles': [25],
22+
'indexes': ["testindex"],
23+
});
24+
25+
assert.equal(await isWritten(5000), true);
26+
return new Response("");
27+
},
28+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Workerd = import "/workerd/workerd.capnp";
2+
3+
const analyticsWorker :Workerd.Worker = (
4+
modules = [
5+
(name = "worker", esModule =
6+
`import * as assert from 'node:assert';
7+
`export default {
8+
` async fetch(request, env, ctx) {
9+
` let val = await request.json();
10+
` console.log(JSON.stringify(val));
11+
` assert.deepStrictEqual(val['dataset'], [97,110,97,108,121,116,105,99,115]);
12+
` assert.deepStrictEqual(val['double1'], 25);
13+
` assert.deepStrictEqual(val['blob1'], [84,101,115,116,66,108,111,98]);
14+
` await env.main.fetch("http://w/");
15+
` return new Response('');
16+
` },
17+
`};
18+
),
19+
],
20+
compatibilityFlags = ["experimental", "nodejs_compat"],
21+
compatibilityDate = "2023-02-28",
22+
bindings = [ ( name = "main", service = "main" ) ]
23+
);
24+
25+
const mainWorker :Workerd.Worker = (
26+
modules = [
27+
(name = "worker", esModule = embed "analytics-engine-test.js"),
28+
],
29+
compatibilityFlags = ["experimental", "nodejs_compat"],
30+
compatibilityDate = "2023-02-28",
31+
32+
bindings = [ ( name = "aebinding", analyticsEngine = "analytics") ]
33+
);
34+
35+
const unitTests :Workerd.Config = (
36+
services = [ (name = "main", worker = .mainWorker), (name = "analytics", worker = .analyticsWorker) ],
37+
);

src/workerd/server/server.c++

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
#include <kj/compat/url.h>
1010
#include <kj/encoding.h>
1111
#include <kj/map.h>
12+
#include <capnp/message.h>
13+
#include <capnp/compat/json.h>
14+
#include <workerd/api/analytics-engine.capnp.h>
1215
#include <workerd/io/worker-interface.h>
1316
#include <workerd/io/worker-entrypoint.h>
1417
#include <workerd/io/compatibility-date.h>
@@ -19,6 +22,7 @@
1922
#include <openssl/pem.h>
2023
#include <workerd/io/actor-cache.h>
2124
#include <workerd/io/actor-sqlite.h>
25+
#include <workerd/util/http-util.h>
2226
#include <workerd/api/actor-state.h>
2327
#include <workerd/util/mimetype.h>
2428
#include "workerd-api.h"
@@ -1566,7 +1570,38 @@ private:
15661570

15671571
kj::Promise<void> writeLogfwdr(uint channel,
15681572
kj::FunctionParam<void(capnp::AnyPointer::Builder)> buildMessage) override {
1569-
KJ_FAIL_REQUIRE("no logging channels");
1573+
auto& context = IoContext::current();
1574+
1575+
auto headers = kj::HttpHeaders(context.getHeaderTable());
1576+
auto client = context.getHttpClient(channel, true, nullptr, "writeLogfwdr"_kjc);
1577+
1578+
auto urlStr = kj::str("https://fake-host");
1579+
1580+
capnp::MallocMessageBuilder requestMessage;
1581+
auto requestBuilder = requestMessage.initRoot<capnp::AnyPointer>();
1582+
1583+
buildMessage(requestBuilder);
1584+
capnp::JsonCodec json;
1585+
auto requestJson = json.encode(requestBuilder.getAs<api::AnalyticsEngineEvent>());
1586+
1587+
co_await context.waitForOutputLocks();
1588+
1589+
auto innerReq = client->request(kj::HttpMethod::POST, urlStr, headers, requestJson.size());
1590+
1591+
struct RefcountedWrapper: public kj::Refcounted {
1592+
explicit RefcountedWrapper(kj::Own<kj::HttpClient> client): client(kj::mv(client)) {}
1593+
kj::Own<kj::HttpClient> client;
1594+
};
1595+
auto rcClient = kj::refcounted<RefcountedWrapper>(kj::mv(client));
1596+
auto request = attachToRequest(kj::mv(innerReq), kj::mv(rcClient));
1597+
1598+
co_await request.body->write(requestJson.begin(), requestJson.size())
1599+
.attach(kj::mv(requestJson), kj::mv(request.body));
1600+
auto response = co_await request.response;
1601+
1602+
KJ_REQUIRE(response.statusCode >= 200 && response.statusCode < 300, "writeLogfwdr request returned an error");
1603+
co_await response.body->readAllBytes().attach(kj::mv(response.body)).ignoreResult();
1604+
co_return;
15701605
}
15711606

15721607
kj::Own<ActorChannel> getGlobalActor(uint channel, const ActorIdFactory::ActorId& id,
@@ -1649,7 +1684,8 @@ static kj::Maybe<WorkerdApiIsolate::Global> createBinding(
16491684
Worker::ValidationErrorReporter& errorReporter,
16501685
kj::Vector<FutureSubrequestChannel>& subrequestChannels,
16511686
kj::Vector<FutureActorChannel>& actorChannels,
1652-
kj::HashMap<kj::String, kj::HashMap<kj::String, Server::ActorConfig>>& actorConfigs) {
1687+
kj::HashMap<kj::String, kj::HashMap<kj::String, Server::ActorConfig>>& actorConfigs,
1688+
bool experimental) {
16531689
// creates binding object or returns null and reports an error
16541690
using Global = WorkerdApiIsolate::Global;
16551691
kj::StringPtr bindingName = binding.getName();
@@ -1883,7 +1919,7 @@ static kj::Maybe<WorkerdApiIsolate::Global> createBinding(
18831919
kj::Vector<Global> innerGlobals;
18841920
for (const auto& innerBinding: wrapped.getInnerBindings()) {
18851921
KJ_IF_MAYBE(global, createBinding(workerName, conf, innerBinding,
1886-
errorReporter, subrequestChannels, actorChannels, actorConfigs)) {
1922+
errorReporter, subrequestChannels, actorChannels, actorConfigs, experimental)) {
18871923
innerGlobals.add(kj::mv(*global));
18881924
} else {
18891925
// we've already communicated the error
@@ -1908,6 +1944,25 @@ static kj::Maybe<WorkerdApiIsolate::Global> createBinding(
19081944
}
19091945
}
19101946

1947+
case config::Worker::Binding::ANALYTICS_ENGINE: {
1948+
if (!experimental) {
1949+
errorReporter.addError(kj::str(
1950+
"AnalyticsEngine bindings are an experimental feature which may change or go away in the future."
1951+
"You must run workerd with `--experimental` to use this feature."));
1952+
}
1953+
1954+
uint channel = (uint)subrequestChannels.size() + IoContext::SPECIAL_SUBREQUEST_CHANNEL_COUNT;
1955+
subrequestChannels.add(FutureSubrequestChannel {
1956+
binding.getAnalyticsEngine(),
1957+
kj::mv(errorContext)
1958+
});
1959+
1960+
return makeGlobal(Global::AnalyticsEngine{
1961+
.subrequestChannel = channel,
1962+
.dataset = kj::str(binding.getAnalyticsEngine().getName()),
1963+
.version = 0,
1964+
});
1965+
}
19111966
}
19121967
errorReporter.addError(kj::str(
19131968
errorContext, "has unrecognized type. Was the config compiled with a newer version of "
@@ -2031,7 +2086,8 @@ kj::Own<Server::Service> Server::makeWorker(kj::StringPtr name, config::Worker::
20312086
kj::Vector<Global> globals(confBindings.size());
20322087
for (auto binding: confBindings) {
20332088
KJ_IF_MAYBE(global, createBinding(name, conf, binding, errorReporter,
2034-
subrequestChannels, actorChannels, actorConfigs)) {
2089+
subrequestChannels, actorChannels, actorConfigs,
2090+
experimental)) {
20352091
globals.add(kj::mv(*global));
20362092
}
20372093
}

src/workerd/server/workerd-api.c++

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,8 @@ private:
487487
static v8::Local<v8::Value> createBindingValue(
488488
JsgWorkerdIsolate::Lock& lock,
489489
const WorkerdApiIsolate::Global& global,
490-
CompatibilityFlags::Reader featureFlags) {
490+
CompatibilityFlags::Reader featureFlags,
491+
uint32_t ownerId) {
491492
using Global = WorkerdApiIsolate::Global;
492493
auto context = lock.v8Context();
493494

@@ -561,6 +562,12 @@ static v8::Local<v8::Value> createBindingValue(
561562
kj::heap<ActorIdFactoryImpl>(ns.uniqueKey)));
562563
}
563564

565+
KJ_CASE_ONEOF(ae, Global::AnalyticsEngine) {
566+
// Use subrequestChannel as logfwdrChannel
567+
value = lock.wrap(context, jsg::alloc<api::AnalyticsEngine>(ae.subrequestChannel,
568+
kj::str(ae.dataset), ae.version, ownerId));
569+
}
570+
564571
KJ_CASE_ONEOF(text, kj::String) {
565572
value = lock.wrap(context, kj::mv(text));
566573
}
@@ -585,7 +592,7 @@ static v8::Local<v8::Value> createBindingValue(
585592
for (const auto& innerBinding: wrapped.innerBindings) {
586593
jsg::check(env->Set(context,
587594
lock.wrapString(innerBinding.name),
588-
createBindingValue(lock, innerBinding, featureFlags)));
595+
createBindingValue(lock, innerBinding, featureFlags, ownerId)));
589596
}
590597

591598
// obtain exported function to call
@@ -620,7 +627,7 @@ void WorkerdApiIsolate::compileGlobals(
620627

621628
// Don't use String's usual TypeHandler here because we want to intern the string.
622629
auto name = jsg::v8StrIntern(lock.v8Isolate, global.name);
623-
auto value = createBindingValue(lock, global, featureFlags);
630+
auto value = createBindingValue(lock, global, featureFlags, ownerId);
624631

625632
KJ_ASSERT(!value.IsEmpty(), "global did not produce v8::Value");
626633
bool setResult = jsg::check(target->Set(context, name, value));
@@ -666,6 +673,9 @@ WorkerdApiIsolate::Global WorkerdApiIsolate::Global::clone() const {
666673
KJ_CASE_ONEOF(ns, Global::DurableActorNamespace) {
667674
result.value = ns.clone();
668675
}
676+
KJ_CASE_ONEOF(ae, Global::AnalyticsEngine) {
677+
result.value = ae.clone();
678+
}
669679
KJ_CASE_ONEOF(text, kj::String) {
670680
result.value = kj::str(text);
671681
}

src/workerd/server/workerd-api.h

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,22 @@ class WorkerdApiIsolate final: public Worker::ApiIsolate {
141141
};
142142
}
143143
};
144+
struct AnalyticsEngine {
145+
uint subrequestChannel;
146+
kj::String dataset;
147+
int64_t version;
148+
AnalyticsEngine clone() const {
149+
return AnalyticsEngine {
150+
.subrequestChannel = subrequestChannel,
151+
.dataset = kj::str(dataset),
152+
.version = version
153+
};
154+
}
155+
};
144156
kj::String name;
145157
kj::OneOf<Json, Fetcher, KvNamespace, R2Bucket, R2Admin, CryptoKey, EphemeralActorNamespace,
146-
DurableActorNamespace, QueueBinding, kj::String, kj::Array<byte>, Wrapped> value;
158+
DurableActorNamespace, QueueBinding, kj::String, kj::Array<byte>, Wrapped,
159+
AnalyticsEngine> value;
147160

148161
Global clone() const;
149162
};

src/workerd/server/workerd.capnp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,12 @@ struct Worker {
351351
# `getenv()` with that name. If the environment variable isn't set, the binding value is
352352
# `null`.
353353

354-
# TODO(someday): dispatch, analyticsEngine, other new features
354+
analyticsEngine @17 :ServiceDesignator;
355+
# A binding for Analytics Engine. Allows workers to store information through Analytics Engine Events.
356+
# workerd will forward AnalyticsEngineEvents to designated service in the body of HTTP requests
357+
# This binding is subject to change and requires the `--experimental` flag
358+
359+
# TODO(someday): dispatch, other new features
355360
}
356361

357362
struct Type {
@@ -372,6 +377,7 @@ struct Worker {
372377
r2Bucket @9 :Void;
373378
r2Admin @10 :Void;
374379
queue @11 :Void;
380+
analyticsEngine @12 : Void;
375381
}
376382
}
377383

0 commit comments

Comments
 (0)