diff --git a/internal/cmd/minipipeline/main.go b/internal/cmd/minipipeline/main.go new file mode 100644 index 0000000000..e89a985e6b --- /dev/null +++ b/internal/cmd/minipipeline/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "os" + + "github.com/ooni/probe-cli/v3/internal/minipipeline" + "github.com/ooni/probe-cli/v3/internal/must" + "github.com/ooni/probe-cli/v3/internal/pipeline" +) + +func main() { + rawMeasurement := must.ReadFile(os.Args[1]) + var meas pipeline.CanonicalMeasurement + must.UnmarshalJSON(rawMeasurement, &meas) + + container := minipipeline.NewWebObservationsContainer() + container.CreateDNSLookupFailures(meas.TestKeys.Unwrap().Queries...) + container.CreateKnownIPAddresses(meas.TestKeys.Unwrap().Queries...) + container.CreateKnownTCPEndpoints(meas.TestKeys.Unwrap().TCPConnect...) + container.NoteTLSHandshakeResults(meas.TestKeys.Unwrap().TLSHandshakes...) + container.NoteHTTPRoundTripResults(meas.TestKeys.Unwrap().Requests...) + container.NoteControlResults(meas.TestKeys.Unwrap().XControlRequest.Unwrap(), meas.TestKeys.Unwrap().Control.Unwrap()) + + must.WriteFile("db.json", must.MarshalJSON(container), 0600) + + /* + ax := &pipeline.Analysis{} + ax.ComputeAllValues(db) + + must.WriteFile("ax.json", must.MarshalJSON(ax), 0600) + */ +} diff --git a/internal/experiment/webconnectivity/httpanalysis.go b/internal/experiment/webconnectivity/httpanalysis.go index 65ea775b30..23d27d7ea2 100644 --- a/internal/experiment/webconnectivity/httpanalysis.go +++ b/internal/experiment/webconnectivity/httpanalysis.go @@ -202,7 +202,7 @@ func HTTPTitleMatch(tk urlgetter.TestKeys, ctrl ControlResponse) (out *bool) { } control := ctrl.HTTPRequest.Title measurementBody := string(response.Body) - measurement := measurexlite.WebGetTitle(measurementBody) + measurement := measurexlite.WebGetTitleString(measurementBody) if measurement == "" { return } diff --git a/internal/experiment/webconnectivitylte/analysishttpdiff.go b/internal/experiment/webconnectivitylte/analysishttpdiff.go index b25a739506..32475648e4 100644 --- a/internal/experiment/webconnectivitylte/analysishttpdiff.go +++ b/internal/experiment/webconnectivitylte/analysishttpdiff.go @@ -231,7 +231,7 @@ func (tk *TestKeys) httpDiffTitleMatch( } control := ctrl.Title measurementBody := string(response.Body) - measurement := measurexlite.WebGetTitle(measurementBody) + measurement := measurexlite.WebGetTitleString(measurementBody) if control == "" || measurement == "" { return } diff --git a/internal/measurexlite/web.go b/internal/measurexlite/web.go index adc03da6ea..625cc3d74a 100644 --- a/internal/measurexlite/web.go +++ b/internal/measurexlite/web.go @@ -6,12 +6,15 @@ package measurexlite import "regexp" -// WebGetTitle returns the title or an empty string. -func WebGetTitle(measurementBody string) string { - // MK used {1,128} but we're making it larger here to get longer titles - // e.g. 's one - re := regexp.MustCompile(`(?i)([^<]{1,512})`) - v := re.FindStringSubmatch(measurementBody) +// webTitleRegexp is the regexp to extract the title +// +// MK used {1,128} but we're making it larger here to get longer titles +// e.g. 's one +var webTitleRegexp = regexp.MustCompile(`(?i)([^<]{1,512})`) + +// WebGetTitleString returns the title or an empty string. +func WebGetTitleString(measurementBody string) string { + v := webTitleRegexp.FindStringSubmatch(measurementBody) if len(v) < 2 { return "" } diff --git a/internal/measurexlite/web_test.go b/internal/measurexlite/web_test.go index 115ae34108..7f2e8f0146 100644 --- a/internal/measurexlite/web_test.go +++ b/internal/measurexlite/web_test.go @@ -54,7 +54,7 @@ func TestWebGetTitle(t *testing.T) { }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotOut := WebGetTitle(tt.args.body) + gotOut := WebGetTitleString(tt.args.body) if diff := cmp.Diff(tt.wantOut, gotOut); diff != "" { t.Fatal(diff) } diff --git a/internal/minipipeline/doc.go b/internal/minipipeline/doc.go new file mode 100644 index 0000000000..4ebc4167f6 --- /dev/null +++ b/internal/minipipeline/doc.go @@ -0,0 +1,3 @@ +// Package minipipeline implements a minimal data processing pipeline used +// to analyze local measurements collected by OONI Probe. +package minipipeline diff --git a/internal/minipipeline/web.go b/internal/minipipeline/web.go new file mode 100644 index 0000000000..23e906746c --- /dev/null +++ b/internal/minipipeline/web.go @@ -0,0 +1,514 @@ +package minipipeline + +import ( + "net" + "net/url" + "strconv" + "strings" + + "github.com/ooni/probe-cli/v3/internal/geoipx" + "github.com/ooni/probe-cli/v3/internal/measurexlite" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/optional" +) + +// WebObservation is an observation of the flow that starts with a DNS lookup that +// discovers zero or more IP addresses and proceeds with endpoint operations such as +// TCP connect, TLS handshake, QUIC handshake, and HTTP round trips. +// +// A key property of the [WebObservation] is that there is a single failure mode +// for the whole [WebObservation]. If the DNS fails, there are no IP addresses to +// construct endpoints. If TCP connect fails, there is no connection to use for +// a TLS handshake. If QUIC fails, there is no connection. If there is no connection +// we cannot attempt sending an HTTP request, so there's no HTTP round trip. +// +// We borrow this design from https://github.com/ooni/data. +type WebObservation struct { + // ContainsDNSLookupInfo is true if this record contains DNS lookup information. + ContainsDNSLookupInfo bool + + // DNSTransactionIDs contains the IDs of the DNS transactions that caused this + // specific [WebObservation] to be generated by the mini pipeline. + DNSTransactionIDs []int64 + + // DNSDomain is the domain from which we resolved the IP address. This field + // is empty whne this record wasn't generated by a DNS lookup. + DNSDomain optional.Value[string] + + // DNSLookupFailure is the failure that occurred during the DNS lookup. + DNSLookupFailure optional.Value[string] + + // DNSQueryType is the type of the DNS query. + DNSQueryType optional.Value[string] + + // IPAddress is the optional IP address that this observation is about. We derive this value + // either from one or more DNS lookups, or because it's part of the input URL. When it's + // empty, it means the associated DNS lookup operation failed. + IPAddress optional.Value[string] + + // IPAddressASN is the optional ASN associated to this IP address as discovered by + // the probe while performing the measurement. When this field is empty, it means + // that the probe failed to discover the IP address ASN. + IPAddressASN optional.Value[int64] + + // IPAddressOrg is the optional organization name associated to this IP adddress + // as discovered by the probe while performing the measurement. When this field is + // empty, it means that the probe failed to discover the IP address org. + IPAddressOrg optional.Value[string] + + // IPAddressBogon is true if IPAddres is a bogon. + IPAddressBogon bool + + // EndpointTransactionID is the transaction ID used by this endpoint. + EndpointTransactionID int64 + + // EndpointProto is either "tcp" or "udp". + EndpointProto string + + // EndpointPort is the port used by this endpoint. + EndpointPort string + + // EndpointAddress is "${IPAddress}:${EndpointPort}" where "${IPAddress}" is + // quoted using "[" and "]" in case of IPv6. + EndpointAddress string + + // ContainsTCPConnectInfo is true if this struct contains TCP connect information. + ContainsTCPConnectInfo bool + + // TCPConnectFailure is the optional TCP connect failure. + TCPConnectFailure optional.Value[string] + + // ContainsTLSHandshakeInfo is true if this struct contains TLS handshake information. + ContainsTLSHandshakeInfo bool + + // TLSHandshakeFailure is the optional TLS handshake failure. + TLSHandshakeFailure optional.Value[string] + + // TLSServerName is the optional TLS server name used. + TLSServerName optional.Value[string] + + // ContainsHTTPRoundTripInfo is true if this struct contains HTTP round trip information. + ContainsHTTPRoundTripInfo bool + + // HTTPRequestURL is the HTTP request URL. + HTTPRequestURL optional.Value[string] + + // HTTPFailure is the error that occurred. + HTTPFailure optional.Value[string] + + // HTTPResponseStatusCode is the response status code. + HTTPResponseStatusCode optional.Value[int64] + + // HTTPResponseBodyLength is the length of the response body. + HTTPResponseBodyLength optional.Value[int64] + + // HTTPResponseBodyIsTruncated indicates whether the response body is truncated. + HTTPResponseBodyIsTruncated optional.Value[bool] + + // HTTPResponseHeadersKeys contains the response headers keys. + HTTPResponseHeadersKeys map[string]bool + + // HTTPResponseLocation contains the location we're redirected to. + HTTPResponseLocation optional.Value[string] + + // HTTPResponseTitle contains the response title. + HTTPResponseTitle optional.Value[string] + + // ContainsControlDNSLookupInfo is true if we have DNS lookup info from the control. + ContainsControlDNSLookupInfo bool + + // ControlDNSLookupFailure is the corresponding control DNS lookup failure. + ControlDNSLookupFailure optional.Value[string] + + // ContainsControlTCPConnectInfo is true if we have TCP connect info from the control. + ContainsControlTCPConnectInfo bool + + // ControlTCPConnectFailure is the control's TCP connect failure. + ControlTCPConnectFailure optional.Value[string] + + // MatchWithControlIPAddress is true if also the control resolved this IP address. + MatchWithControlIPAddress optional.Value[bool] + + // MatchWithControlIPAddressASN is true if also the control resolved from the same ASN. + MatchWithControlIPAddressASN optional.Value[bool] + + // ContainsControlTLSHandshakeInfo is true if we have TLS handshake info from the control. + ContainsControlTLSHandshakeInfo bool + + // ControlTLSHandshakeFailure is the control's TLS handshake failure. + ControlTLSHandshakeFailure optional.Value[string] + + // ContainsControlHTTPInfo is true if we have HTTP info from the control. + ContainsControlHTTPInfo bool + + // ControlHTTPFailure is the failure seen by the control. + ControlHTTPFailure optional.Value[string] + + // ControlHTTPResponseStatusCode is the status code seen by the control. + ControlHTTPResponseStatusCode optional.Value[int64] + + // ControlHTTPResponseBodyLength contains the control HTTP response body length. + ControlHTTPResponseBodyLength optional.Value[int64] + + // ControlHTTPResponseHeadersKeys contains the response headers keys. + ControlHTTPResponseHeadersKeys map[string]bool + + // ControlHTTPResponseTitle contains the title seen by the control. + ControlHTTPResponseTitle optional.Value[string] +} + +// WebObservationsContainer contains [*WebObservations]. +// +// The zero value of this struct is not ready to use, please use [NewWebObservationsContainer]. +type WebObservationsContainer struct { + // DNSLookupFailures maps domain names to DNS lookup failures. + DNSLookupFailures map[int64]*WebObservation + + // KnownTCPEndpoints maps a transaction ID to the corresponding observation. + KnownTCPEndpoints map[int64]*WebObservation + + // knownIPAddresses maps an IP address to the corresponding observation. + knownIPAddresses map[string]*WebObservation +} + +// NewWebObservationsContainer constructs a [*WebObservationsContainer]. +func NewWebObservationsContainer() *WebObservationsContainer { + return &WebObservationsContainer{ + DNSLookupFailures: map[int64]*WebObservation{}, + KnownTCPEndpoints: map[int64]*WebObservation{}, + knownIPAddresses: map[string]*WebObservation{}, + } +} + +// CreateDNSLookupFailures creates records for failed DNS lookups. +func (c *WebObservationsContainer) CreateDNSLookupFailures(evs ...*model.ArchivalDNSLookupResult) { + for _, ev := range evs { + // skip all the succesful queries + if ev.Failure == nil { + continue + } + + // create record + obs := &WebObservation{ + ContainsDNSLookupInfo: true, + DNSTransactionIDs: []int64{ev.TransactionID}, + DNSDomain: optional.Some(ev.Hostname), + DNSLookupFailure: optional.Some(*ev.Failure), + DNSQueryType: optional.Some(ev.QueryType), + } + + // add record + c.DNSLookupFailures[ev.TransactionID] = obs + } +} + +// CreateKnownIPAddresses creates known IP addresses from succesful DNS lookups. +func (c *WebObservationsContainer) CreateKnownIPAddresses(evs ...*model.ArchivalDNSLookupResult) { + for _, ev := range evs { + // skip all the failed queries + if ev.Failure != nil { + continue + } + + // walk through the answers + for _, answer := range ev.Answers { + // extract the IP address we resolved + var addr string + switch answer.AnswerType { + case "A": + addr = answer.IPv4 + case "AAAA": + addr = answer.IPv6 + default: + continue + } + + // create or fetch a record + obs, found := c.knownIPAddresses[addr] + if !found { + obs = &WebObservation{} + c.knownIPAddresses[addr] = obs + } + + // update the record + obs.ContainsDNSLookupInfo = true + obs.DNSTransactionIDs = append(obs.DNSTransactionIDs, ev.TransactionID) + obs.DNSDomain = optional.Some(ev.Hostname) + obs.DNSLookupFailure = optional.None[string]() + obs.DNSQueryType = optional.Some(ev.QueryType) + obs.IPAddress = optional.Some(addr) + if asn := answer.ASN; asn != 0 { + obs.IPAddressASN = optional.Some(int64(asn)) + } + if org := answer.ASOrgName; org != "" { + obs.IPAddressOrg = optional.Some(org) + } + obs.IPAddressBogon = netxlite.IsBogon(addr) + } + } +} + +// CreateKnownTCPEndpoints creates known TCP endpoints from TCP connect attempts. +func (c *WebObservationsContainer) CreateKnownTCPEndpoints(evs ...*model.ArchivalTCPConnectResult) { + for _, ev := range evs { + // create or fetch a record + obs, found := c.knownIPAddresses[ev.IP] + if !found { + obs = &WebObservation{ + IPAddress: optional.Some(ev.IP), + IPAddressBogon: netxlite.IsBogon(ev.IP), + } + if asn, asOrg, err := geoipx.LookupASN(ev.IP); err == nil { + obs.IPAddressASN = optional.Some(int64(asn)) + obs.IPAddressOrg = optional.Some(asOrg) + } + } + + // clone the record because the same IP address MAY belong + // to multiple endpoints across the same measurement + // + // while there also fill endpoint specific info + portString := strconv.Itoa(ev.Port) + obs = &WebObservation{ + ContainsDNSLookupInfo: obs.ContainsDNSLookupInfo, + DNSTransactionIDs: append([]int64{}, obs.DNSTransactionIDs...), + DNSDomain: obs.DNSDomain, + DNSLookupFailure: obs.DNSLookupFailure, + IPAddress: obs.IPAddress, + IPAddressASN: obs.IPAddressASN, + IPAddressOrg: obs.IPAddressOrg, + IPAddressBogon: obs.IPAddressBogon, + EndpointTransactionID: ev.TransactionID, + EndpointProto: "tcp", + EndpointPort: portString, + EndpointAddress: net.JoinHostPort(ev.IP, portString), + ContainsTCPConnectInfo: true, + TCPConnectFailure: optional.None[string](), + } + + // finish filling the endpoint + if value := ev.Status.Failure; value != nil { + obs.TCPConnectFailure = optional.Some(*value) + } + + // register the observation + c.KnownTCPEndpoints[ev.TransactionID] = obs + } +} + +// NoteTLSHandshakeResults updates endpoints taking into account TLS handshake results. +func (c *WebObservationsContainer) NoteTLSHandshakeResults(evs ...*model.ArchivalTLSOrQUICHandshakeResult) { + for _, ev := range evs { + // find the corresponding obs + obs, found := c.KnownTCPEndpoints[ev.TransactionID] + if !found { + continue + } + + // update the record + obs.ContainsTLSHandshakeInfo = true + if value := ev.Failure; value != nil { + obs.TLSHandshakeFailure = optional.Some(*value) + } + if value := ev.ServerName; value != "" { + obs.TLSServerName = optional.Some(value) + } + } +} + +// NoteHTTPRoundTripResults updates endpoints taking into account HTTP round trip results. +func (c *WebObservationsContainer) NoteHTTPRoundTripResults(evs ...*model.ArchivalHTTPRequestResult) { + for _, ev := range evs { + // find the corresponding obs + obs, found := c.KnownTCPEndpoints[ev.TransactionID] + if !found { + continue + } + + // update the record + obs.ContainsHTTPRoundTripInfo = true + obs.HTTPRequestURL = optional.Some(ev.Request.URL) + if value := ev.Failure; value != nil { + obs.HTTPFailure = optional.Some(*value) + } + if value := ev.Response.Code; value != 0 { + obs.HTTPResponseStatusCode = optional.Some(value) + } + if value := int64(len(ev.Response.Body)); value > 0 { + obs.HTTPResponseBodyLength = optional.Some(value) + } + obs.HTTPResponseBodyIsTruncated = optional.Some(ev.Request.BodyIsTruncated) + obs.HTTPResponseHeadersKeys = make(map[string]bool) + for key := range ev.Response.Headers { + obs.HTTPResponseHeadersKeys[key] = true + } + if value := measurexlite.WebGetTitleString(string(ev.Response.Body)); value != "" { + obs.HTTPResponseTitle = optional.Some(value) + } + for key, value := range ev.Response.Headers { + if strings.ToLower(key) == "location" { + obs.HTTPResponseLocation = optional.Some(string(value)) + } + } + } +} + +// NoteControlResults takes note of the control's results and updates observations accordingly. +func (c *WebObservationsContainer) NoteControlResults(req *model.THRequest, resp *model.THResponse) error { + + URL, err := url.Parse(req.HTTPRequest) + if err != nil { + return err + } + inputDomain := URL.Hostname() + + c.controlXrefDNSFailures(inputDomain, resp) + c.controlMatchDNSLookupResults(inputDomain, resp) + c.controlXrefEndpointFailures(resp) + c.controlXrefFinalHTTPResponse(resp) + + return nil +} + +func (c *WebObservationsContainer) controlMatchDNSLookupResults(inputDomain string, resp *model.THResponse) { + // rebuild the list of ASNs using the probe's database because we want to + // use the same exact database we used for processing the measurement + thASNMap := make(map[string]int64) + for _, addr := range resp.DNS.Addrs { + if asn, _, err := geoipx.LookupASN(addr); err == nil && asn != 0 { + thASNMap[addr] = int64(asn) + } + } + + // walk through the list of known observations + for _, obs := range c.KnownTCPEndpoints { + // skip entries without a domain to resolve (likely a bug) + domain := obs.DNSDomain.UnwrapOr("") + if domain == "" { + continue + } + + // skip entries using a different domain than the one used by the TH + if domain != inputDomain { + continue + } + + // skip entries without an IP address (likely a bug) + addr := obs.IPAddress.UnwrapOr("") + if addr == "" { + continue + } + + // attempt to access the TH's ASN map for the probe's address + thASN, found := thASNMap[addr] + + // register whether they both resolved the same IP address + obs.MatchWithControlIPAddress = optional.Some(found) + + // cannot continue unless we know the probe's ASN + ourASN := obs.IPAddressASN.UnwrapOr(0) + if ourASN == 0 { + continue + } + + // register whether there is matching in terms of the ASNs + obs.MatchWithControlIPAddressASN = optional.Some(thASN == ourASN) + } +} + +func (c *WebObservationsContainer) controlXrefDNSFailures(inputDomain string, resp *model.THResponse) { + for _, obs := range c.DNSLookupFailures { + // skip cases where we don't know the domain we were using (bug) + domain := obs.DNSDomain.UnwrapOr("") + if domain == "" { + continue + } + + // skip cases where the DNS lookup did not fail (bug) + probeFailure := obs.DNSLookupFailure.UnwrapOr("") + if probeFailure == "" { + continue + } + + // skip cases where the domain is different + if domain != inputDomain { + continue + } + + // register the corresponding DNS lookup failure + obs.ContainsControlDNSLookupInfo = true + if value := resp.DNS.Failure; value != nil { + obs.ControlDNSLookupFailure = optional.Some(*value) + } + } +} + +func (c *WebObservationsContainer) controlXrefEndpointFailures(resp *model.THResponse) { + for _, obs := range c.KnownTCPEndpoints { + // search for the corresponding control TCP connect entry + if tcp, found := resp.TCPConnect[obs.EndpointAddress]; found { + obs.ContainsControlTCPConnectInfo = true + if value := tcp.Failure; value != nil { + obs.ControlTCPConnectFailure = optional.Some(*value) + } + } + + // search for the corresponding TLS handshake entry for the same server name + if serverName := obs.TLSServerName.UnwrapOr(""); serverName != "" { + if tls, found := resp.TLSHandshake[obs.EndpointAddress]; found && tls.ServerName == serverName { + obs.ContainsControlTLSHandshakeInfo = true + if value := tls.Failure; value != nil { + obs.ControlTLSHandshakeFailure = optional.Some(*value) + } + } + } + } +} + +func (c *WebObservationsContainer) controlXrefFinalHTTPResponse(resp *model.THResponse) { + obs := c.findFinalHTTPResponse().UnwrapOr(nil) + if obs == nil { + return + } + obs.ContainsControlHTTPInfo = true + if value := resp.HTTPRequest.Failure; value != nil { + obs.ControlHTTPFailure = optional.Some(*value) + } + if value := resp.HTTPRequest.StatusCode; value > 0 { + obs.ControlHTTPResponseStatusCode = optional.Some(value) + } + if value := resp.HTTPRequest.BodyLength; value > 0 { + obs.ControlHTTPResponseBodyLength = optional.Some(value) + } + obs.ControlHTTPResponseHeadersKeys = make(map[string]bool) + for key := range resp.HTTPRequest.Headers { + obs.ControlHTTPResponseHeadersKeys[key] = true + } + if v := resp.HTTPRequest.Title; v != "" { + obs.ControlHTTPResponseTitle = optional.Some(v) + } +} + +func (c *WebObservationsContainer) findFinalHTTPResponse() optional.Value[*WebObservation] { + // find all the possible final request candidates + var candidates []*WebObservation + for _, wobs := range c.KnownTCPEndpoints { + switch code := wobs.HTTPResponseStatusCode.UnwrapOr(0); code { + case 0, 301, 302, 307, 308: + // this is a redirect or a nonexisting response in the case of zero + + default: + // found candidate + candidates = append(candidates, wobs) + } + } + + // Implementation note: the final request is a request that is not a redirect and + // we expect to see just one of them. This code is written assuming we will have + // more than a final request in the future and to fail in such a case. + if len(candidates) != 1 { + return optional.None[*WebObservation]() + } + return optional.Some(candidates[0]) +} diff --git a/internal/oohelperd/http.go b/internal/oohelperd/http.go index e6867187ee..0b9d221a70 100644 --- a/internal/oohelperd/http.go +++ b/internal/oohelperd/http.go @@ -120,7 +120,7 @@ func httpDo(ctx context.Context, config *httpConfig) { Failure: httpMapFailure(err), StatusCode: int64(resp.StatusCode), Headers: headers, - Title: measurexlite.WebGetTitle(string(data)), + Title: measurexlite.WebGetTitleString(string(data)), } } diff --git a/internal/pipeline/canonical.go b/internal/pipeline/canonical.go index 39fe839a58..1cc5ec7d6f 100644 --- a/internal/pipeline/canonical.go +++ b/internal/pipeline/canonical.go @@ -39,4 +39,7 @@ type CanonicalTestKeys struct { // QUICHandshakes contains the QUIC handshakes results. QUICHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"quic_handshakes"` + + // XControlRequest contains the OPTIONAL TH request. + XControlRequest optional.Value[*model.THRequest] `json:"x_control_request"` } diff --git a/internal/pipeline/web.go b/internal/pipeline/web.go index 584b47a6ad..1e811bb3e6 100644 --- a/internal/pipeline/web.go +++ b/internal/pipeline/web.go @@ -212,7 +212,7 @@ func (db *DB) addHTTPRoundTrips(evs ...*model.ArchivalHTTPRequestResult) error { for key := range ev.Response.Headers { wobs.HTTPResponseHeadersKeys[key] = OriginProbe } - if title := measurexlite.WebGetTitle(string(ev.Response.Body)); title != "" { + if title := measurexlite.WebGetTitleString(string(ev.Response.Body)); title != "" { wobs.HTTPResponseTitle = optional.Some(title) } }