HTTP
This section documents the "REST" (*) API that can be used for initiating communication with the PowerGoblin from the SUT (system under test) or any other system. The goal of the API is to provide an easy-to-use interface for scripts to control the measurement process.
By default, the program does not offer any modern security features. For example, the interface communication is not encrypted, which means that the connection should only be used in a controlled environment or properly firewalled and tunnelled using encrypted connections. On the other hand, this has an advantage because encryption would increase the latency of the communication and could even affect the measurement's results.
However, the scope of the program's operations is limited to the output file directories defined for the program. If necessary, a more restrictive sandbox can be built for the program, for example with container technologies.
(*) JSON/RPC would probably better describe how the interface works.
Control commands
The following examples demonstrate the use of the PowerGoblin HTTP API in common programming languages (Shell, Python, Java, JavaScript, Kotlin).
Shell scripting is probably most useful if the measurement is coordinated by external scripts that are not part of the software to be measured. This way the software can be seen as a black box. Such scripts should be compatible with most systems that support Unix shell scripts. Note that shell scripts might be problematic for high performance measurement because of the latency introduced by the script interpreters and invocation of operating system processes.
Python scripting is most useful for measurements triggered by a front-end Selenium script because the trigger points can be selected more freely and for example the initialization of the browser instance can be discarded.
Java and JavaScript examples are provided for other possible scenarios. For instance, a backend server based on Spring Boot or Node.js could be the source of trigger events.
# Requires sh, curl/wget & coreutils
# GET requests using wget / curl
msg_get() {
wget -qO- "http://$HOST/api/v2/$*"
}
msg_get() {
curl "http://$HOST/api/v2/$*"
}
# POST requests using wget/curl (stdin stored to a file)
msg_post() {
FILE="$(mktemp)"
cat > "$FILE"
wget -qO- --post-file "$FILE" "http://$HOST/api/v2/$*"
rm "$FILE"
}
msg_post() {
curl -d@- "http://$HOST/api/v2/$*"
}
# POST request using wget/curl (send a file)
msg_post_file() {
wget -qO- --post-file "$1" http://$HOST/api/v2/$2
}
msg_post_file() {
curl --data-binary "@$1" http://$HOST/api/v2/$2
}
# --- Examples ---
# PowerGoblin server
export HOST=localhost:8080
# GET examples
msg_get cmd/startSession
msg_get session/latest/meter
msg_get session/latest/measurement/start
# POST examples
export DATA='{ "triggerType":"Run", "unit":"SUT", "message":"foo", "type":"trigger" }'
echo $DATA | msg_post session/latest/trigger
echo foobar | msg_post session/latest/measurement/rename
msg_post_file collectd.zip session/latest/import/collectd
# Requires Python 3+, python-requests, python-simplejson
import requests
import json
class GoblinClient:
def __init__(self, host):
self.host = "http://" + host + "/api/v2/"
def get(self, url):
return requests.get(self.host + url)
def post_json(self, url, json):
return requests.post(self.host + url, json = json)
def post_text(self, url, text):
h = {'Content-Type': 'text/plain'}
return requests.post(self.host + url, data = text, headers=h)
def post_file(self, url, file):
with open(file, 'rb') as p:
h = {'content-type': 'application/x-zip'}
return requests.post(self.host + url, data=p, verify=False, headers=h)
def interpret(self, result):
return json.loads(result.content)["result"]
# --- Examples ---
# PowerGoblin server
c = GoblinClient("localhost:8080")
# GET examples
c.get("cmd/startSession")
meters = c.get("session/latest/meter")
print(c.interpret(meters))
c.get("session/latest/measurement/start")
# POST examples
data = { "triggerType":"Run", "unit":"SUT", "message":"foo", "type":"trigger" }
c.post_json("session/latest/trigger", json = data)
c.post_text("session/latest/measurement/rename", text = "foobar")
c.post_file("session/latest/import/collectd", "collectd.zip")
// Requires Java 21+
import java.io.IOException;
import java.nio.file.*;
import java.net.*;
import java.net.http.*;
import java.util.zip.*;
record GoblinClient(String host) {
private HttpResponse<String> run(HttpRequest.Builder b) throws Exception {
try (var client = HttpClient.newHttpClient()) {
return client.send(
b.build(),
HttpResponse.BodyHandlers.ofString()
);
}
}
private HttpRequest.Builder api(String api) {
return HttpRequest.newBuilder(URI.create("http://"+host+"/api/v2/"+api));
}
HttpResponse<String> get(String api) throws Exception {
return run(api(api));
}
HttpResponse<String> post(String api, String msg) throws Exception {
return run(api(api).POST(HttpRequest.BodyPublishers.ofString(msg)));
}
HttpResponse<String> post(String api, Path path) throws Exception {
return run(api(api).POST(HttpRequest.BodyPublishers.ofFile(path)));
}
}
// --- Examples ---
void main() {
var c = new GoblinClient("localhost:8080");
// GET examples
c.get("cmd/startSession");
c.get("session/latest/meter");
c.get("session/latest/measurement/start");
// POST examples
var data = """
{ "triggerType": "Run", "unit": "SUT", "message": "foo", "type": "trigger" }
""";
c.post("session/latest/trigger", data);
c.post("session/latest/rename", "foobar");
c.post("session/latest/import/collectd", Path.of("collectd.zip"));
}
const request = require('request');
const host = "localhost:8080";
function get(api) {
request(
"http://" + host + "/api/v2/" + api,
(e, r, body) => {
if (!e && r.statusCode == 200)
console.log(body);
}
);
}
function post_json(api, data) {
request.post(
{
url: "http://" + host + "/api/v2/" + api,
json: data,
},
(e, r, body) => {
if (!e && r.statusCode == 200)
console.log(body)
}
);
}
// GET examples
get("cmd/startSession");
get("session/latest/meter");
get("session/latest/measurement/start");
// POST examples
var data = { "triggerType" : "Run", "unit" : "SUT", "message": "foo", "type": "trigger" };
post_json("session/latest/trigger", data);
post_json("session/latest/rename", "foobar");
// Requires Java 21+
import java.nio.file.*
import java.net.*
import java.net.http.*
import java.util.zip.*
class GoblinClient(val host: String) {
private fun run(b: HttpRequest.Builder) =
HttpClient.newHttpClient().use {
it.send(
b.build(),
HttpResponse.BodyHandlers.ofString()
)
}
private fun api(api: String) =
HttpRequest.newBuilder(URI.create("http://$host/api/v2/$api"))
fun get(api: String) = run(api(api))
fun post(api: String, msg: String) =
run(api(api).POST(HttpRequest.BodyPublishers.ofString(msg)))
fun post(api: String, path: Path) =
run(api(api).POST(HttpRequest.BodyPublishers.ofFile(path)))
}
// --- Examples ---
var c = GoblinClient("localhost:8080")
// GET examples
c.get("cmd/startSession")
c.get("session/latest/meter")
c.get("session/latest/measurement/start")
// POST examples
var data = """
{ "triggerType": "Run", "unit": "SUT", "message": "foo", "type": "trigger" }
"""
c.post("session/latest/trigger", data)
c.post("session/latest/rename", "foobar")
c.post("session/latest/import/collectd", Path.of("collectd.zip"))
The shell scripts assume that the environment variable $HOST points to the host and port running PowerGoblin. The Python and Java versions use object-oriented approach where the server's address and port are provided for the constructor. Further processing of the HTTP reply data requires a JSON parser. We have included one in the Python example.
The default port for the web interface is 8080. The SUT is supposed to be a separate system to minimize the risk of interference, but the commands can be executed on any system, including the one running PowerGoblin, as well. This flexibility allows setting up the measurement in a multitude of ways.
The start & stop run / measurement commands are also available via the web UI. The commands for storing the data is there as well.
HTTP API description
THIS REPRESENTS THE NEW (v2) HTTP API! You can find the documentation for the old version (v1) here.
The following control commands encode the command's name and possible parameters as part of the HTTP POST query. The following table describes the meaning of the URLs:
| Command | Type | Description |
|---|---|---|
api/v2/session |
GET Query | Return the state data for all active and inactive sessions in this instance. |
api/v2/session/latest |
GET Query | Return the state data for the most recently created session (i.e. session with the highest id value) |
api/v2/session/:id |
GET Query | Return the state data for the session 'id'. |
api/v2/session/:id/restore/:path |
GET Cmd | Restores a past session from logs/path (***), note: 'id' is ignored. |
api/v2/session/:id/remove |
GET Cmd | Removes the session 'id' from this instance. |
api/v2/session/:id/close |
GET Cmd | Closes the session 'id' (may autosave depending on the settings). |
api/v2/session/:id/store |
GET Cmd | Stores the session 'id' to disk (in the log directory). |
api/v2/session/:id/reset |
GET Cmd | Resets the session 'id' (wipes all session data). |
api/v2/session/:id/rename/:name |
GET Event | Rename the session 'id' to 'name' (****) |
api/v2/session/:id/sync/:timestamp/:unit |
GET Event | Synchronize the clocks between the 'unit' (optional) and the instance (*). |
api/v2/session/:id/resource |
GET Query | Return a summary of resource consumption |
api/v2/session/:id/resource/:resources/include |
GET Event | Add the resource filters 'resources' to session 'id' |
api/v2/session/:id/resource/:resources/exclude |
GET Event | Remove the resource filters 'resources' from session 'id' |
api/v2/session/:id/resource/:resource/add/:value/:unit |
GET Event | Add a custom resource entry for unit 'unit' and resource 'resource' in session 'id'. The value can be of any type. |
api/v2/session/:id/logs/power/:measurement/:meter/:channel |
GET Query | Returns the power readings for the meter & channel & measurement in the session 'id', for doing scatter plots. |
api/v2/session/:id/logs/resource/:measurement/:unit/:resource |
GET Query | Returns the resource readings for the unit & resource & measurement in the session 'id', for doing scatter plots. |
api/v2/session/:id/logs/session |
GET Query | Returns a summary of the session state. |
api/v2/session/:id/logs/busy |
GET Query | Returns a boolean value indicating whether the session logs are still in busy state. |
api/v2/session/:id/logs/path |
GET Query | Returns a string value representing the session's directory in the log directory. |
api/v2/session/:id/meter |
GET Query | Return the meter states for all meters in session 'id' |
api/v2/session/:id/meter/:meter |
GET Query | Return the meter states for meter 'meter' in session 'id' |
api/v2/session/:id/meter/:meters/toggle |
GET Cmd | Toggle the meter 'id' on or off (all the drivers may not have support for this). |
api/v2/session/:id/meter/:meters/add |
GET Cmd | Assign the free meters 'meters' as new assigned meters to session 'id' |
api/v2/session/:id/meter/:meters/remove |
GET Cmd | Unassign the assigned meters 'meters' from session 'id' |
api/v2/session/:id/meter/:meters/set |
GET Cmd | Assign meters 'meters' to session 'id' and do not include any other meters in the session |
api/v2/session/:id/meter/:meter/rename/:channel/:name |
GET Event | Rename the meter 'meter' & channel 'channel' as 'name' in session 'id' |
api/v2/session/:id/measurement |
GET Query | Return the state of the past measurements |
api/v2/session/:id/measurement/:id |
GET Query | Return the state of the past measurement with id 'id' |
api/v2/session/:id/measurement/active |
GET Query | Return the state of the currently active measurement |
api/v2/session/:id/measurement/start/:unit/:msg |
GET Event | Start a new measurement, initiated by 'unit', in session 'id'. Optional 'msg' |
api/v2/session/:id/measurement/stop/:unit/:msg |
GET Event | Stop the measurement, initiated by 'unit', in session 'id'. Optional 'msg' |
api/v2/session/:id/measurement/rename/:name |
GET Event | Rename the active measurement to 'name' in session 'id' |
api/v2/session/:id/run |
GET Query | Return the status of the past runs. |
api/v2/session/:id/run/:id |
GET Query | Return the status of the past run with id 'id'. |
api/v2/session/:id/run/active |
GET Query | Return the status of the currently active run. |
api/v2/session/:id/run/start/:unit/:msg |
GET Event | Start a new run, initiated by 'unit', in session 'id'. Optional 'msg' |
api/v2/session/:id/run/stop/:unit/:msg |
GET Event | Stop the run, initiated by 'unit', in session 'id'. Optional 'msg' |
api/v2/session/:id/trigger/:name/:unit/:msg |
GET Event | Create a new trigger event name, initiated by 'unit', in session 'id'. Optional 'msg' |
api/v2/session/:id/cmd/:type |
GET Cmd | Executes a session command of the type 'type', associated with session 'id' |
api/v2/cmd/:type |
GET Cmd | Executes a non-session command of the type 'type' |
api/v2/meter |
GET Query | Return the status of all meters managed by the instance |
api/v2/meter/:id |
GET Query | Return the status of the meter 'id' |
api/v2/meter/:id/toggle |
GET Cmd | Toggle the meter 'id' on or off (all the drivers may not have support for this). |
api/v2/mqtt/:topic/:message |
GET Cmd | Send MQTT message 'message' using topic 'topic'. |
api/v2/instance |
GET Query | Return a state of the instance |
api/v2/instance/logs |
GET Query | Return the number of log files available in the instance |
api/v2/reconfigure |
GET Cmd | Reconfigure the meters in the instance, e.g. for hot-plugging new meters. |
api/v2/benchmark/http |
GET Cmd | Start a HTTP benchmark. The perf result can be read from the logs. |
api/v2/benchmark/telnet |
GET Cmd | Start a Telnet benchmark. The perf result can be read from the logs. |
api/v2/session/:id/restore |
POST Cmd | Restores a past session given as POST data, 'id' is ignored |
api/v2/session/:id/rename |
POST Event | Rename the session 'id' to 'name' given as POST data. |
api/v2/session/:id/sync |
POST Event | Synchronize the clocks between the unit 'sut' and the instance (*), time stamp (**) given as POST data. |
api/v2/session/:id/resource/:resources/include |
POST Event | Add the resource filters 'resources' to session 'id' |
api/v2/session/:id/resource/:resources/exclude |
POST Event | Remove the resource filters 'resources' from session 'id' |
api/v2/session/:id/import/collectd |
POST Event | Import collectd data, the data is provided as an attached zip file. |
api/v2/session/:id/meter/:meters/toggle |
POST Cmd | Toggle the meter 'id' on or off (all the drivers may not have support for this). |
api/v2/session/:id/meter/:meters/add |
POST Cmd | Assign the free meters 'meters' as new assigned meters to session 'id' |
api/v2/session/:id/meter/:meters/remove |
POST Cmd | Unassign the assigned meters 'meters' from session 'id' |
api/v2/session/:id/meter/:meters/set |
POST Cmd | Assign meters 'meters' to session 'id' and do not include any other meters in the session |
api/v2/session/:id/meter/:meter/rename/:channel |
POST Event | Rename the meter 'meter' & channel 'channel' as 'name' given as POST data in session 'id' |
api/v2/session/:id/measurement/start/:unit |
POST Event | Start a new measurement, initiated by 'unit', in session 'id'. Optional 'msg' given as POST data. |
api/v2/session/:id/measurement/stop/:unit |
POST Event | Stop the measurement, initiated by 'unit', in session 'id'. Optional 'msg' given as POST data. |
api/v2/session/:id/measurement/rename |
POST Event | Rename the active measurement to 'name' given as POST data in session 'id' |
api/v2/session/:id/run/start/:unit |
POST Event | Start a new run, initiated by 'unit', in session 'id'. Optional 'msg' given as POST data. |
api/v2/session/:id/run/stop/:unit |
POST Event | Stop the run, initiated by 'unit', in session 'id'. Optional 'msg' given as POST data. |
api/v2/session/:id/trigger |
POST Event | Create a new trigger event, JSON serialized trigger data given as POST data. |
api/v2/cmd |
POST Cmd | Executes a command, JSON serialized command data given as POST data. |
api/v2/mqtt/:topic |
POST Cmd | Send MQTT message using topic 'topic'. The meesage is given as POST data. |
Notes
(*) Currently only a single (latest) synchronization point is supported per unit. Measuring of clock drift is not supported. In the future, support for multiple points and linear interpolation of clock drift may be added. In such a case, setting up only one synchronization point will operate in the same way as before.
(**) The supported timestamp format looks like date +%s%N, using the date utility from GNU coreutils:
$ date +%s%N
1744729470653712269
(***) For security reasons, relative paths or absolute paths pointing outside the log directory will be discarded.
(***) Renaming the session will switch to a new the log directory. All events that were logged before the rename operation remain in the old directory.
Telnet
TODO: The Telnet API is still mimicking the sessionless version of the v1 HTTP API!
In addition to the HTTP API, PowerGoblin also provides a simple Telnet style interface for communication. Using this interface is potentially more straightforward and produces lower latency due to simpler command sending and parsing. The API is currently limited to Linux as it uses the io_uring backend for even higher performance and lower latency.
Control commands
The following examples demonstrate the use of the PowerGoblin Telnet API in common programming languages (Shell, Python, Java, JavaScript).
Shell scripting is probably most useful if the measurement is coordinated by external scripts that are not part of the software to be measured. This way the software can be seen as a black box. Such scripts should be compatible with most systems that support Unix shell scripts. Note that shell scripts might be problematic for high performance measurement because of the latency introduced by the script interpreters and invocation of operating system processes.
Python scripting is most useful for measurements triggered by a front-end Selenium script because the trigger points can be selected more freely and for example the initialization of the browser instance can be discarded.
Java and JavaScript examples are provided for other possible scenarios. For instance, a backend server based on Spring Boot or Node.js could be the source of trigger events.
# Requires sh, coreutils & gnu-netcat or busybox
msg() {
echo $* | nc $HOST $PORT
}
# --- Examples ---
# PowerGoblin server
export HOST=localhost
export PORT=9000
msg SESSION METERS
msg MEASUREMENT START starting
msg SESSION SYNC $(date +%s%N)
msg TRIGGER Run,SUT,start
msg SESSION RENAME foobar
# Requires Python 3+
import socket
import time
class GoblinClient:
def __init__(self, host, port):
self.host = host
self.port = port
def send(self, msg):
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.connect((self.host, self.port))
self.socket.sendall(bytes(msg, "utf-8"))
self.socket.close()
def milli_time():
return str(round(time.time() * 1000))
# --- Examples ---
c = GoblinClient("localhost", 9000)
c.send("SESSION METERS")
c.send("MEASUREMENT START starting")
c.send("SESSION SYNC " + milli_time())
c.send("TRIGGER Run,SUT,start")
c.send("SESSION RENAME foobar")
// requires Java 21+
import java.net.*;
import java.io.*;
public record GoblinClient(String host, int port) {
void send(String msg) throws Exception {
try (
var s = new Socket(host, port);
var w = new PrintWriter(s.getOutputStream())
) {
w.println(msg);
}
}
}
// --- Examples ---
void main() {
var c = new GoblinClient("localhost", 9000);
c.send("SESSION METERS");
c.send("MEASUREMENT START starting");
c.send("SESSION SYNC " + System.currentTimeMillis());
c.send("TRIGGER Run,SUT,start");
c.send("SESSION RENAME foobar");
}
var net = require('net');
const host = "127.0.0.1";
const port = 9000;
function send(msg) {
var client = new net.Socket();
client.connect(port, host, function() {
client.write(msg);
});
client.on('data', function(data) {
console.log("RESULT: "+data);
client.destroy();
});
}
send("SESSION METERS");
send("MEASUREMENT START starting");
send("SESSION SYNC " + System.currentTimeMillis());
send("TRIGGER Run,SUT,start");
send("SESSION RENAME foobar");
// requires Java 21+
import java.net.*
import java.io.*
class GoblinClient(val host: String, val port: int) {
fun send(msg: String) {
Socket(host, port).use {
PrintWriter(it.outputStream).use {
it.println(msg)
}
}
}
// --- Examples ---
var c = GoblinClient("localhost", 9000)
c.send("SESSION METERS")
c.send("MEASUREMENT START starting")
c.send("SESSION SYNC " + System.currentTimeMillis())
c.send("TRIGGER Run,SUT,start")
c.send("SESSION RENAME foobar")
The shell scripts assume that the environment variable $HOST points to the host and and $PORT to the port running PowerGoblin. The Python and Java versions use object-oriented approach where the server's address and port are provided for the constructor. Further processing of the reply data requires a JSON parser.
The default port for the Telnet interface is 9000. The SUT is supposed to be a separate system to minimize the risk of interference, but the commands can be executed on any system, including the one running PowerGoblin, as well. This flexibility allows setting up the measurement in a multitude of ways.
Telnet API description
The following control commands encode the command's name and possible parameters as part a plain text query. Multiple parameters are comma separated. The following table describes the meaning of the commands:
| Command | Type | Description |
|---|---|---|
| SESSION | Query | Return a hierarchical structure of the session state |
| SESSION METERS | Query | Return the list of active meters in the session |
| SESSION STORE | Cmd | Store the session data |
| SESSION CLOSE | Cmd | Close the session (also stores the data) |
| SESSION RESET | Cmd | Reset the session data (dangerous, deletes everything) |
| SESSION RECONFIGURE | Cmd | Add/remove meters after a hot plug event. |
| SESSION RENAME :name | Event | Rename the session to 'name' |
| SESSION SYNC :timestamp | Event | Synchronize the session clocks between the SUT and PowerGoblin instance (*). |
| MEASUREMENT | Query | Return the status of the current measurement |
| MEASUREMENT RENAME :name | Event | Rename the measurement to 'name' |
| MEASUREMENT START :unit | Event | Start a new measurement, initiated by 'unit' |
| MEASUREMENT STOP :unit | Event | Stop the measurement, initiated by 'unit' |
| RUN | Query | Return the status of the current run |
| RUN START :unit | Event | Start a new run, initiated by 'unit' |
| RUN STOP :unit | Event | Stop the run, initiated by 'unit' |
| METER | Query | Return the status of all the meters in the session |
| METER :id | Query | Return the status of the 'id' |
| METER RENAME :id,:name | Event | Rename the meter 'id' to 'name' |
| TRIGGER :name,:unit,:msg | Event | Create a new trigger event name, unit unit, msg msg |
| CMD exit | Cmd | Shut down the application. |
| CMD deleteLogs | Cmd | Delete all logs in the log directory. |
| CMD deletePlots | Cmd | Delete all plots in the plot directory. |
| BENCH HTTP | Cmd | Start a HTTP benchmark. The perf result can be read from the logs. |
| BENCH TELNET | Cmd | Start a Telnet benchmark. The perf result can be read from the logs. |
(*) Currently only a single (latest) sync point is supported and the measuring of clock drift is not supported. In the future multiple points may be supported.
The supported timestamp format looks like date +%s%N, using the date utility from GNU coreutils:
$ date +%s%N
1744729470653712269
MQTT
PowerGoblin also provides an interface for monitoring PowerGoblin instances, their sessions and measurement using the MQTT protocol.
Since MQTT messages are routed through the MQTT broker, the protocol does not allow for as low-latency communication as direct connection via HTTP or Telnet protocols. In fact, latency can be very high with MQTT. The first version of PowerGoblin supported coordinating measurements using MQTT, but we found that the latency affected the measurement results so much that the feature has now been removed in PowerGoblin 2. We now use MQTT communication only for non-time-critical remote monitoring.
Currently, when MQTT has been enabled (--mqtt command line switch), the PowerGoblin instance first discovers the hostname of the host system, and subscribes to the topic lab-HOSTNAME/. JSON encoded status events are submitted to lab-HOSTNAME/INSTANCE_ID. The instance ID is the SHA-1 hash of the JSON encoded instance specification structure. Since the launch time affects this structure, it is likely that even two instances running on the same host have different ID values.
The encoding of the messages is defined by the instance of fi.utu.tech.powergoblin.model.StatusEvent. Currently, the following status events are supported:
| Name | Type | Description |
|---|---|---|
InstanceStartedEvent |
instanceStarted |
The PowerGoblin instance was started. |
InstanceStoppedEvent |
instanceStopped |
The PowerGoblin instance was stopped. |
InstanceSessionStartedEvent |
instanceSessionStarted |
A new measurement session was started on this instance. |
InstanceSessionClosedEvent |
instanceSessionClosed |
A measurement session was closed on this instance. |
InstanceReconfigureEvent |
instanceReconfigure |
The PowerGoblin instance was reconfigured (discovery of new meters). |
InstanceBenchmarkEvent |
instanceBenchmark |
The PowerGoblin instance executed a benchmark. |
InstanceSessionEvent |
instanceSession |
A session related event was executed (measurement / run was started / stopped, meter set on / off). |
Subscribing to MQTT status events
First, check the hostname on the machine running PowerGoblin with:
$ hostname
MYHOST
Next, on any system in the same LAN, execute:
$ mosquitto_sub -h BROKER -t lab-MYHOST/#
Here, the BROKER is the IP / hostname of the machine running the MQTT broker / server (e.g. mosquitto).
You can stop the subscription by pressing Ctrl-C.


